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