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    /// 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
260impl AuditSink {
261    /// Open `path` for appending, creating it if needed.
262    pub fn file(path: impl AsRef<Path>) -> Result<Self> {
263        Self::file_with_limit(path, None)
264    }
265
266    /// Open `path`, rotating to `<path>.1` once it passes `max_bytes`.
267    ///
268    /// One generation is kept. A trail that grows without bound eventually
269    /// fills the disk it is meant to protect, and keeping several generations
270    /// would be a retention policy — which belongs to whoever runs the machine,
271    /// not to this process.
272    pub fn file_with_limit(path: impl AsRef<Path>, max_bytes: Option<u64>) -> Result<Self> {
273        let path = path.as_ref().to_path_buf();
274        let (writer, written) = open_append(&path)?;
275        Ok(Self::File {
276            path,
277            max_bytes,
278            state: Mutex::new(FileState { writer, written }),
279        })
280    }
281
282    /// Whether anything is being recorded.
283    pub fn is_enabled(&self) -> bool {
284        matches!(self, Self::File { .. })
285    }
286
287    /// Record one event.
288    ///
289    /// Flushed per event rather than buffered until convenient: a trail that
290    /// loses its last entries when the process dies is least trustworthy exactly
291    /// when it matters most.
292    pub fn record(&self, event: AuditEvent) {
293        let Self::File {
294            path,
295            max_bytes,
296            state,
297        } = self
298        else {
299            return;
300        };
301
302        let line = match serde_json::to_string(&event) {
303            Ok(line) => line,
304            Err(e) => {
305                tracing::warn!(target: "audit", "cannot encode audit event: {e}");
306                return;
307            }
308        };
309
310        let Ok(mut state) = state.lock() else {
311            tracing::warn!(target: "audit", "audit log lock poisoned; event dropped");
312            return;
313        };
314
315        // Rotated before the write, so the limit bounds the file rather than
316        // being the point at which it is already over.
317        if let Some(limit) = max_bytes {
318            if state.written + line.len() as u64 + 1 > *limit && state.written > 0 {
319                if let Err(e) = rotate(path, &mut state) {
320                    tracing::warn!(target: "audit", "cannot rotate audit log {}: {e}", path.display());
321                }
322            }
323        }
324
325        match writeln!(state.writer, "{line}").and_then(|()| state.writer.flush()) {
326            Ok(()) => state.written += line.len() as u64 + 1,
327            Err(e) => {
328                // Logged, not fatal: losing the trail should not take the server
329                // down, but it must not pass silently either.
330                tracing::warn!(target: "audit", "cannot write audit log {}: {e}", path.display());
331            }
332        }
333    }
334}
335
336/// Open a file for appending, reporting how much is already in it.
337fn open_append(path: &Path) -> Result<(BufWriter<File>, u64)> {
338    let file = OpenOptions::new()
339        .create(true)
340        .append(true)
341        .open(path)
342        .map_err(|e| {
343            ShellTunnelError::Io(std::io::Error::new(
344                e.kind(),
345                format!("cannot open audit log {}: {e}", path.display()),
346            ))
347        })?;
348    let written = file.metadata().map(|m| m.len()).unwrap_or(0);
349    Ok((BufWriter::new(file), written))
350}
351
352/// Move the current file aside and start a fresh one.
353fn rotate(path: &Path, state: &mut FileState) -> std::io::Result<()> {
354    state.writer.flush()?;
355
356    let rotated = path.with_extension(match path.extension() {
357        Some(ext) => format!("{}.1", ext.to_string_lossy()),
358        None => "1".to_string(),
359    });
360    // Replaces the previous generation: one is kept, deliberately.
361    std::fs::rename(path, &rotated)?;
362
363    let (writer, _) = open_append(path).map_err(std::io::Error::other)?;
364    state.writer = writer;
365    state.written = 0;
366    Ok(())
367}
368
369/// Milliseconds since the Unix epoch.
370fn now_ms() -> u64 {
371    SystemTime::now()
372        .duration_since(UNIX_EPOCH)
373        .map(|d| d.as_millis() as u64)
374        .unwrap_or(0)
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    fn read_lines(path: &Path) -> Vec<AuditEvent> {
382        std::fs::read_to_string(path)
383            .unwrap()
384            .lines()
385            .map(|line| serde_json::from_str(line).expect("each line is one event"))
386            .collect()
387    }
388
389    #[test]
390    fn a_disabled_sink_records_nothing() {
391        let sink = AuditSink::Disabled;
392        assert!(!sink.is_enabled());
393        sink.record(AuditEvent::new("execute"));
394    }
395
396    #[test]
397    fn events_are_appended_one_per_line() {
398        let dir = tempfile::tempdir().unwrap();
399        let path = dir.path().join("audit.jsonl");
400        let sink = AuditSink::file(&path).unwrap();
401
402        sink.record(AuditEvent::new("execute").with_command("echo one"));
403        sink.record(AuditEvent::new("execute").with_command("echo two"));
404
405        let events = read_lines(&path);
406        assert_eq!(events.len(), 2);
407        assert_eq!(events[0].command.as_deref(), Some("echo one"));
408        assert_eq!(events[1].command.as_deref(), Some("echo two"));
409    }
410
411    #[test]
412    fn reopening_appends_rather_than_truncating() {
413        let dir = tempfile::tempdir().unwrap();
414        let path = dir.path().join("audit.jsonl");
415
416        AuditSink::file(&path)
417            .unwrap()
418            .record(AuditEvent::new("execute").with_command("first run"));
419        AuditSink::file(&path)
420            .unwrap()
421            .record(AuditEvent::new("execute").with_command("second run"));
422
423        // A trail that restarts empty on every restart is not a trail.
424        let events = read_lines(&path);
425        assert_eq!(events.len(), 2);
426    }
427
428    #[test]
429    fn an_execution_event_carries_who_what_and_outcome() {
430        let dir = tempfile::tempdir().unwrap();
431        let path = dir.path().join("audit.jsonl");
432        let sink = AuditSink::file(&path).unwrap();
433
434        sink.record(
435            AuditEvent::new("execute")
436                .with_identity(Some(Identity {
437                    token_id: "tok-1".into(),
438                    label: "operator".into(),
439                }))
440                .with_client("203.0.113.7:51000")
441                .with_route("POST /api/v1/execute")
442                .with_command("whoami")
443                .with_outcome(Some(0), false, 42),
444        );
445
446        let event = read_lines(&path).remove(0);
447        assert_eq!(event.kind, "execute");
448        assert_eq!(event.identity.unwrap().label, "operator");
449        assert_eq!(event.command.as_deref(), Some("whoami"));
450        assert_eq!(event.exit_code, Some(0));
451        assert_eq!(event.timed_out, Some(false));
452        assert_eq!(event.duration_ms, Some(42));
453        assert!(event.at_ms > 0);
454    }
455
456    #[test]
457    fn a_denial_records_why_without_the_token() {
458        let dir = tempfile::tempdir().unwrap();
459        let path = dir.path().join("audit.jsonl");
460        let sink = AuditSink::file(&path).unwrap();
461
462        sink.record(
463            AuditEvent::new("denied")
464                .with_client("198.51.100.4:40000")
465                .with_route("POST /api/v1/execute")
466                .with_denial(401, "invalid-token"),
467        );
468
469        let raw = std::fs::read_to_string(&path).unwrap();
470        let event = read_lines(&path).remove(0);
471        assert_eq!(event.status, Some(401));
472        assert_eq!(event.reason.as_deref(), Some("invalid-token"));
473        // Probing is what these entries are for, and the credential that was
474        // tried must not end up in the file.
475        assert!(!raw.contains("Bearer"), "{raw}");
476    }
477
478    #[test]
479    fn a_bounded_log_rotates_instead_of_growing() {
480        let dir = tempfile::tempdir().unwrap();
481        let path = dir.path().join("audit.jsonl");
482        // Small enough that the second event cannot share the file.
483        let sink = AuditSink::file_with_limit(&path, Some(200)).unwrap();
484
485        for i in 0..8 {
486            sink.record(AuditEvent::new("execute").with_command(format!("command number {i}")));
487        }
488
489        let current = std::fs::metadata(&path).unwrap().len();
490        assert!(
491            current <= 200,
492            "current file should stay under the limit: {current}"
493        );
494
495        // The previous generation is kept, so the most recent history survives
496        // a rotation rather than being discarded.
497        let rotated = dir.path().join("audit.jsonl.1");
498        assert!(rotated.exists(), "one generation should be kept");
499    }
500
501    #[test]
502    fn an_unbounded_log_never_rotates() {
503        let dir = tempfile::tempdir().unwrap();
504        let path = dir.path().join("audit.jsonl");
505        let sink = AuditSink::file(&path).unwrap();
506
507        for i in 0..20 {
508            sink.record(AuditEvent::new("execute").with_command(format!("command {i}")));
509        }
510
511        assert_eq!(read_lines(&path).len(), 20);
512        assert!(!dir.path().join("audit.jsonl.1").exists());
513    }
514
515    #[test]
516    fn rotation_keeps_counting_from_an_existing_file() {
517        let dir = tempfile::tempdir().unwrap();
518        let path = dir.path().join("audit.jsonl");
519
520        // A restart must not forget how full the file already is, or the limit
521        // would only apply to whatever this process wrote.
522        AuditSink::file(&path)
523            .unwrap()
524            .record(AuditEvent::new("execute").with_command("x".repeat(150)));
525        let sink = AuditSink::file_with_limit(&path, Some(200)).unwrap();
526        sink.record(AuditEvent::new("execute").with_command("second"));
527
528        assert!(dir.path().join("audit.jsonl.1").exists());
529    }
530
531    #[test]
532    fn absent_fields_are_omitted_rather_than_null() {
533        let dir = tempfile::tempdir().unwrap();
534        let path = dir.path().join("audit.jsonl");
535        AuditSink::file(&path)
536            .unwrap()
537            .record(AuditEvent::new("execute"));
538
539        let raw = std::fs::read_to_string(&path).unwrap();
540        assert!(!raw.contains("null"), "{raw}");
541    }
542}