Skip to main content

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    /// HTTP status, for denial events.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub status: Option<u16>,
73    /// Why a request was refused (`missing-token`, `invalid-token`, …).
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub reason: Option<String>,
76}
77
78impl AuditEvent {
79    /// Start an event of the given kind, stamped now.
80    pub fn new(kind: impl Into<String>) -> Self {
81        Self {
82            at_ms: now_ms(),
83            kind: kind.into(),
84            identity: None,
85            client: None,
86            route: None,
87            command: None,
88            session_id: None,
89            exit_code: None,
90            timed_out: None,
91            duration_ms: None,
92            status: None,
93            reason: None,
94        }
95    }
96
97    /// Attach the caller's identity.
98    pub fn with_identity(mut self, identity: Option<Identity>) -> Self {
99        self.identity = identity;
100        self
101    }
102
103    /// Attach the client address.
104    pub fn with_client(mut self, client: impl Into<String>) -> Self {
105        self.client = Some(client.into());
106        self
107    }
108
109    /// Attach the request route.
110    pub fn with_route(mut self, route: impl Into<String>) -> Self {
111        self.route = Some(route.into());
112        self
113    }
114
115    /// Attach the command line that ran.
116    pub fn with_command(mut self, command: impl Into<String>) -> Self {
117        self.command = Some(command.into());
118        self
119    }
120
121    /// Attach the session the command ran in.
122    pub fn with_session(mut self, session_id: u64) -> Self {
123        self.session_id = Some(session_id);
124        self
125    }
126
127    /// Attach the outcome of an execution.
128    pub fn with_outcome(
129        mut self,
130        exit_code: Option<i32>,
131        timed_out: bool,
132        duration_ms: u64,
133    ) -> Self {
134        self.exit_code = exit_code;
135        self.timed_out = Some(timed_out);
136        self.duration_ms = Some(duration_ms);
137        self
138    }
139
140    /// Attach a refusal.
141    pub fn with_denial(mut self, status: u16, reason: impl Into<String>) -> Self {
142        self.status = Some(status);
143        self.reason = Some(reason.into());
144        self
145    }
146}
147
148/// Where audit events go.
149///
150/// Disabled unless a path is configured: writing to a file nobody asked for
151/// would be a surprising side effect, and the operator is the one who knows
152/// where such a file belongs.
153#[derive(Debug, Default)]
154pub enum AuditSink {
155    /// Nothing is recorded.
156    #[default]
157    Disabled,
158    /// Appended to a file, one JSON object per line.
159    File {
160        /// Path being appended to, kept for diagnostics.
161        path: PathBuf,
162        /// Size at which the file is rotated, if bounded.
163        max_bytes: Option<u64>,
164        state: Mutex<FileState>,
165    },
166}
167
168/// Open file plus what has been written to it.
169#[derive(Debug)]
170pub struct FileState {
171    writer: BufWriter<File>,
172    /// Bytes in the current file, tracked rather than stat-ed so rotation costs
173    /// nothing per event.
174    written: u64,
175}
176
177impl AuditSink {
178    /// Open `path` for appending, creating it if needed.
179    pub fn file(path: impl AsRef<Path>) -> Result<Self> {
180        Self::file_with_limit(path, None)
181    }
182
183    /// Open `path`, rotating to `<path>.1` once it passes `max_bytes`.
184    ///
185    /// One generation is kept. A trail that grows without bound eventually
186    /// fills the disk it is meant to protect, and keeping several generations
187    /// would be a retention policy — which belongs to whoever runs the machine,
188    /// not to this process.
189    pub fn file_with_limit(path: impl AsRef<Path>, max_bytes: Option<u64>) -> Result<Self> {
190        let path = path.as_ref().to_path_buf();
191        let (writer, written) = open_append(&path)?;
192        Ok(Self::File {
193            path,
194            max_bytes,
195            state: Mutex::new(FileState { writer, written }),
196        })
197    }
198
199    /// Whether anything is being recorded.
200    pub fn is_enabled(&self) -> bool {
201        matches!(self, Self::File { .. })
202    }
203
204    /// Record one event.
205    ///
206    /// Flushed per event rather than buffered until convenient: a trail that
207    /// loses its last entries when the process dies is least trustworthy exactly
208    /// when it matters most.
209    pub fn record(&self, event: AuditEvent) {
210        let Self::File {
211            path,
212            max_bytes,
213            state,
214        } = self
215        else {
216            return;
217        };
218
219        let line = match serde_json::to_string(&event) {
220            Ok(line) => line,
221            Err(e) => {
222                tracing::warn!(target: "audit", "cannot encode audit event: {e}");
223                return;
224            }
225        };
226
227        let Ok(mut state) = state.lock() else {
228            tracing::warn!(target: "audit", "audit log lock poisoned; event dropped");
229            return;
230        };
231
232        // Rotated before the write, so the limit bounds the file rather than
233        // being the point at which it is already over.
234        if let Some(limit) = max_bytes {
235            if state.written + line.len() as u64 + 1 > *limit && state.written > 0 {
236                if let Err(e) = rotate(path, &mut state) {
237                    tracing::warn!(target: "audit", "cannot rotate audit log {}: {e}", path.display());
238                }
239            }
240        }
241
242        match writeln!(state.writer, "{line}").and_then(|()| state.writer.flush()) {
243            Ok(()) => state.written += line.len() as u64 + 1,
244            Err(e) => {
245                // Logged, not fatal: losing the trail should not take the server
246                // down, but it must not pass silently either.
247                tracing::warn!(target: "audit", "cannot write audit log {}: {e}", path.display());
248            }
249        }
250    }
251}
252
253/// Open a file for appending, reporting how much is already in it.
254fn open_append(path: &Path) -> Result<(BufWriter<File>, u64)> {
255    let file = OpenOptions::new()
256        .create(true)
257        .append(true)
258        .open(path)
259        .map_err(|e| {
260            ShellTunnelError::Io(std::io::Error::new(
261                e.kind(),
262                format!("cannot open audit log {}: {e}", path.display()),
263            ))
264        })?;
265    let written = file.metadata().map(|m| m.len()).unwrap_or(0);
266    Ok((BufWriter::new(file), written))
267}
268
269/// Move the current file aside and start a fresh one.
270fn rotate(path: &Path, state: &mut FileState) -> std::io::Result<()> {
271    state.writer.flush()?;
272
273    let rotated = path.with_extension(match path.extension() {
274        Some(ext) => format!("{}.1", ext.to_string_lossy()),
275        None => "1".to_string(),
276    });
277    // Replaces the previous generation: one is kept, deliberately.
278    std::fs::rename(path, &rotated)?;
279
280    let (writer, _) = open_append(path).map_err(std::io::Error::other)?;
281    state.writer = writer;
282    state.written = 0;
283    Ok(())
284}
285
286/// Milliseconds since the Unix epoch.
287fn now_ms() -> u64 {
288    SystemTime::now()
289        .duration_since(UNIX_EPOCH)
290        .map(|d| d.as_millis() as u64)
291        .unwrap_or(0)
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    fn read_lines(path: &Path) -> Vec<AuditEvent> {
299        std::fs::read_to_string(path)
300            .unwrap()
301            .lines()
302            .map(|line| serde_json::from_str(line).expect("each line is one event"))
303            .collect()
304    }
305
306    #[test]
307    fn a_disabled_sink_records_nothing() {
308        let sink = AuditSink::Disabled;
309        assert!(!sink.is_enabled());
310        sink.record(AuditEvent::new("execute"));
311    }
312
313    #[test]
314    fn events_are_appended_one_per_line() {
315        let dir = tempfile::tempdir().unwrap();
316        let path = dir.path().join("audit.jsonl");
317        let sink = AuditSink::file(&path).unwrap();
318
319        sink.record(AuditEvent::new("execute").with_command("echo one"));
320        sink.record(AuditEvent::new("execute").with_command("echo two"));
321
322        let events = read_lines(&path);
323        assert_eq!(events.len(), 2);
324        assert_eq!(events[0].command.as_deref(), Some("echo one"));
325        assert_eq!(events[1].command.as_deref(), Some("echo two"));
326    }
327
328    #[test]
329    fn reopening_appends_rather_than_truncating() {
330        let dir = tempfile::tempdir().unwrap();
331        let path = dir.path().join("audit.jsonl");
332
333        AuditSink::file(&path)
334            .unwrap()
335            .record(AuditEvent::new("execute").with_command("first run"));
336        AuditSink::file(&path)
337            .unwrap()
338            .record(AuditEvent::new("execute").with_command("second run"));
339
340        // A trail that restarts empty on every restart is not a trail.
341        let events = read_lines(&path);
342        assert_eq!(events.len(), 2);
343    }
344
345    #[test]
346    fn an_execution_event_carries_who_what_and_outcome() {
347        let dir = tempfile::tempdir().unwrap();
348        let path = dir.path().join("audit.jsonl");
349        let sink = AuditSink::file(&path).unwrap();
350
351        sink.record(
352            AuditEvent::new("execute")
353                .with_identity(Some(Identity {
354                    token_id: "tok-1".into(),
355                    label: "operator".into(),
356                }))
357                .with_client("203.0.113.7:51000")
358                .with_route("POST /api/v1/execute")
359                .with_command("whoami")
360                .with_outcome(Some(0), false, 42),
361        );
362
363        let event = read_lines(&path).remove(0);
364        assert_eq!(event.kind, "execute");
365        assert_eq!(event.identity.unwrap().label, "operator");
366        assert_eq!(event.command.as_deref(), Some("whoami"));
367        assert_eq!(event.exit_code, Some(0));
368        assert_eq!(event.timed_out, Some(false));
369        assert_eq!(event.duration_ms, Some(42));
370        assert!(event.at_ms > 0);
371    }
372
373    #[test]
374    fn a_denial_records_why_without_the_token() {
375        let dir = tempfile::tempdir().unwrap();
376        let path = dir.path().join("audit.jsonl");
377        let sink = AuditSink::file(&path).unwrap();
378
379        sink.record(
380            AuditEvent::new("denied")
381                .with_client("198.51.100.4:40000")
382                .with_route("POST /api/v1/execute")
383                .with_denial(401, "invalid-token"),
384        );
385
386        let raw = std::fs::read_to_string(&path).unwrap();
387        let event = read_lines(&path).remove(0);
388        assert_eq!(event.status, Some(401));
389        assert_eq!(event.reason.as_deref(), Some("invalid-token"));
390        // Probing is what these entries are for, and the credential that was
391        // tried must not end up in the file.
392        assert!(!raw.contains("Bearer"), "{raw}");
393    }
394
395    #[test]
396    fn a_bounded_log_rotates_instead_of_growing() {
397        let dir = tempfile::tempdir().unwrap();
398        let path = dir.path().join("audit.jsonl");
399        // Small enough that the second event cannot share the file.
400        let sink = AuditSink::file_with_limit(&path, Some(200)).unwrap();
401
402        for i in 0..8 {
403            sink.record(AuditEvent::new("execute").with_command(format!("command number {i}")));
404        }
405
406        let current = std::fs::metadata(&path).unwrap().len();
407        assert!(
408            current <= 200,
409            "current file should stay under the limit: {current}"
410        );
411
412        // The previous generation is kept, so the most recent history survives
413        // a rotation rather than being discarded.
414        let rotated = dir.path().join("audit.jsonl.1");
415        assert!(rotated.exists(), "one generation should be kept");
416    }
417
418    #[test]
419    fn an_unbounded_log_never_rotates() {
420        let dir = tempfile::tempdir().unwrap();
421        let path = dir.path().join("audit.jsonl");
422        let sink = AuditSink::file(&path).unwrap();
423
424        for i in 0..20 {
425            sink.record(AuditEvent::new("execute").with_command(format!("command {i}")));
426        }
427
428        assert_eq!(read_lines(&path).len(), 20);
429        assert!(!dir.path().join("audit.jsonl.1").exists());
430    }
431
432    #[test]
433    fn rotation_keeps_counting_from_an_existing_file() {
434        let dir = tempfile::tempdir().unwrap();
435        let path = dir.path().join("audit.jsonl");
436
437        // A restart must not forget how full the file already is, or the limit
438        // would only apply to whatever this process wrote.
439        AuditSink::file(&path)
440            .unwrap()
441            .record(AuditEvent::new("execute").with_command("x".repeat(150)));
442        let sink = AuditSink::file_with_limit(&path, Some(200)).unwrap();
443        sink.record(AuditEvent::new("execute").with_command("second"));
444
445        assert!(dir.path().join("audit.jsonl.1").exists());
446    }
447
448    #[test]
449    fn absent_fields_are_omitted_rather_than_null() {
450        let dir = tempfile::tempdir().unwrap();
451        let path = dir.path().join("audit.jsonl");
452        AuditSink::file(&path)
453            .unwrap()
454            .record(AuditEvent::new("execute"));
455
456        let raw = std::fs::read_to_string(&path).unwrap();
457        assert!(!raw.contains("null"), "{raw}");
458    }
459}