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