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 from an async context, off the runtime's workers.
288    ///
289    /// [`record`](Self::record) opens, writes and flushes a file, which is
290    /// blocking work — the filesystem handlers already thread it into the
291    /// `spawn_blocking` bodies they are running in for that reason, so a slow
292    /// disk cannot starve the worker pool that also runs `/health` and the
293    /// accept loop. A handler that has no blocking body of its own has nowhere
294    /// to put it and used to call `record` straight from the runtime thread.
295    /// This is that missing half: same write, same ordering.
296    ///
297    /// Awaited rather than detached, deliberately. Spawning and walking away
298    /// would return the response first and leave the entry to land whenever —
299    /// or not at all, if the process stops in between. An audit trail that
300    /// drops its last entries under load is untrustworthy exactly where it is
301    /// load-bearing, which is the same reason `record` flushes per event.
302    ///
303    /// The hop is skipped entirely when nothing is being recorded: with no
304    /// trail configured `record` returns immediately, and paying for a task
305    /// dispatch to do nothing would be a cost on every request of the default
306    /// configuration.
307    pub async fn record_async(self: &std::sync::Arc<Self>, event: AuditEvent) {
308        if !self.is_enabled() {
309            return;
310        }
311        let sink = std::sync::Arc::clone(self);
312        // A panic inside the blocking task would already have aborted the
313        // process through the panic hook; the join error is not actionable
314        // here and dropping it keeps this from being a second failure mode.
315        let _ = tokio::task::spawn_blocking(move || sink.record(event)).await;
316    }
317
318    /// Record one event.
319    ///
320    /// Flushed per event rather than buffered until convenient: a trail that
321    /// loses its last entries when the process dies is least trustworthy exactly
322    /// when it matters most.
323    ///
324    /// Blocking. From an async context use
325    /// [`record_async`](Self::record_async), or call this inside a
326    /// `spawn_blocking` body that is already running.
327    pub fn record(&self, event: AuditEvent) {
328        let Self::File {
329            path,
330            max_bytes,
331            state,
332        } = self
333        else {
334            return;
335        };
336
337        let line = match serde_json::to_string(&event) {
338            Ok(line) => line,
339            Err(e) => {
340                tracing::warn!(target: "audit", "cannot encode audit event: {e}");
341                return;
342            }
343        };
344
345        let Ok(mut state) = state.lock() else {
346            tracing::warn!(target: "audit", "audit log lock poisoned; event dropped");
347            return;
348        };
349
350        // Rotated before the write, so the limit bounds the file rather than
351        // being the point at which it is already over.
352        if let Some(limit) = max_bytes {
353            if state.written + line.len() as u64 + 1 > *limit && state.written > 0 {
354                if let Err(e) = rotate(path, &mut state) {
355                    tracing::warn!(target: "audit", "cannot rotate audit log {}: {e}", path.display());
356                }
357            }
358        }
359
360        match writeln!(state.writer, "{line}").and_then(|()| state.writer.flush()) {
361            Ok(()) => state.written += line.len() as u64 + 1,
362            Err(e) => {
363                // Logged, not fatal: losing the trail should not take the server
364                // down, but it must not pass silently either.
365                tracing::warn!(target: "audit", "cannot write audit log {}: {e}", path.display());
366            }
367        }
368    }
369}
370
371/// Open a file for appending, reporting how much is already in it.
372fn open_append(path: &Path) -> Result<(BufWriter<File>, u64)> {
373    let file = OpenOptions::new()
374        .create(true)
375        .append(true)
376        .open(path)
377        .map_err(|e| {
378            ShellTunnelError::Io(std::io::Error::new(
379                e.kind(),
380                format!("cannot open audit log {}: {e}", path.display()),
381            ))
382        })?;
383    let written = file.metadata().map(|m| m.len()).unwrap_or(0);
384    Ok((BufWriter::new(file), written))
385}
386
387/// Move the current file aside and start a fresh one.
388fn rotate(path: &Path, state: &mut FileState) -> std::io::Result<()> {
389    state.writer.flush()?;
390
391    let rotated = path.with_extension(match path.extension() {
392        Some(ext) => format!("{}.1", ext.to_string_lossy()),
393        None => "1".to_string(),
394    });
395    // Replaces the previous generation: one is kept, deliberately.
396    std::fs::rename(path, &rotated)?;
397
398    let (writer, _) = open_append(path).map_err(std::io::Error::other)?;
399    state.writer = writer;
400    state.written = 0;
401    Ok(())
402}
403
404/// Milliseconds since the Unix epoch.
405fn now_ms() -> u64 {
406    SystemTime::now()
407        .duration_since(UNIX_EPOCH)
408        .map(|d| d.as_millis() as u64)
409        .unwrap_or(0)
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415
416    fn read_lines(path: &Path) -> Vec<AuditEvent> {
417        std::fs::read_to_string(path)
418            .unwrap()
419            .lines()
420            .map(|line| serde_json::from_str(line).expect("each line is one event"))
421            .collect()
422    }
423
424    #[test]
425    fn a_disabled_sink_records_nothing() {
426        let sink = AuditSink::Disabled;
427        assert!(!sink.is_enabled());
428        sink.record(AuditEvent::new("execute"));
429    }
430
431    #[test]
432    fn events_are_appended_one_per_line() {
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(AuditEvent::new("execute").with_command("echo one"));
438        sink.record(AuditEvent::new("execute").with_command("echo two"));
439
440        let events = read_lines(&path);
441        assert_eq!(events.len(), 2);
442        assert_eq!(events[0].command.as_deref(), Some("echo one"));
443        assert_eq!(events[1].command.as_deref(), Some("echo two"));
444    }
445
446    #[test]
447    fn reopening_appends_rather_than_truncating() {
448        let dir = tempfile::tempdir().unwrap();
449        let path = dir.path().join("audit.jsonl");
450
451        AuditSink::file(&path)
452            .unwrap()
453            .record(AuditEvent::new("execute").with_command("first run"));
454        AuditSink::file(&path)
455            .unwrap()
456            .record(AuditEvent::new("execute").with_command("second run"));
457
458        // A trail that restarts empty on every restart is not a trail.
459        let events = read_lines(&path);
460        assert_eq!(events.len(), 2);
461    }
462
463    #[test]
464    fn an_execution_event_carries_who_what_and_outcome() {
465        let dir = tempfile::tempdir().unwrap();
466        let path = dir.path().join("audit.jsonl");
467        let sink = AuditSink::file(&path).unwrap();
468
469        sink.record(
470            AuditEvent::new("execute")
471                .with_identity(Some(Identity {
472                    token_id: "tok-1".into(),
473                    label: "operator".into(),
474                }))
475                .with_client("203.0.113.7:51000")
476                .with_route("POST /api/v1/execute")
477                .with_command("whoami")
478                .with_outcome(Some(0), false, 42),
479        );
480
481        let event = read_lines(&path).remove(0);
482        assert_eq!(event.kind, "execute");
483        assert_eq!(event.identity.unwrap().label, "operator");
484        assert_eq!(event.command.as_deref(), Some("whoami"));
485        assert_eq!(event.exit_code, Some(0));
486        assert_eq!(event.timed_out, Some(false));
487        assert_eq!(event.duration_ms, Some(42));
488        assert!(event.at_ms > 0);
489    }
490
491    #[test]
492    fn a_denial_records_why_without_the_token() {
493        let dir = tempfile::tempdir().unwrap();
494        let path = dir.path().join("audit.jsonl");
495        let sink = AuditSink::file(&path).unwrap();
496
497        sink.record(
498            AuditEvent::new("denied")
499                .with_client("198.51.100.4:40000")
500                .with_route("POST /api/v1/execute")
501                .with_denial(401, "invalid-token"),
502        );
503
504        let raw = std::fs::read_to_string(&path).unwrap();
505        let event = read_lines(&path).remove(0);
506        assert_eq!(event.status, Some(401));
507        assert_eq!(event.reason.as_deref(), Some("invalid-token"));
508        // Probing is what these entries are for, and the credential that was
509        // tried must not end up in the file.
510        assert!(!raw.contains("Bearer"), "{raw}");
511    }
512
513    #[test]
514    fn a_bounded_log_rotates_instead_of_growing() {
515        let dir = tempfile::tempdir().unwrap();
516        let path = dir.path().join("audit.jsonl");
517        // Small enough that the second event cannot share the file.
518        let sink = AuditSink::file_with_limit(&path, Some(200)).unwrap();
519
520        for i in 0..8 {
521            sink.record(AuditEvent::new("execute").with_command(format!("command number {i}")));
522        }
523
524        let current = std::fs::metadata(&path).unwrap().len();
525        assert!(
526            current <= 200,
527            "current file should stay under the limit: {current}"
528        );
529
530        // The previous generation is kept, so the most recent history survives
531        // a rotation rather than being discarded.
532        let rotated = dir.path().join("audit.jsonl.1");
533        assert!(rotated.exists(), "one generation should be kept");
534    }
535
536    #[test]
537    fn an_unbounded_log_never_rotates() {
538        let dir = tempfile::tempdir().unwrap();
539        let path = dir.path().join("audit.jsonl");
540        let sink = AuditSink::file(&path).unwrap();
541
542        for i in 0..20 {
543            sink.record(AuditEvent::new("execute").with_command(format!("command {i}")));
544        }
545
546        assert_eq!(read_lines(&path).len(), 20);
547        assert!(!dir.path().join("audit.jsonl.1").exists());
548    }
549
550    #[test]
551    fn rotation_keeps_counting_from_an_existing_file() {
552        let dir = tempfile::tempdir().unwrap();
553        let path = dir.path().join("audit.jsonl");
554
555        // A restart must not forget how full the file already is, or the limit
556        // would only apply to whatever this process wrote.
557        AuditSink::file(&path)
558            .unwrap()
559            .record(AuditEvent::new("execute").with_command("x".repeat(150)));
560        let sink = AuditSink::file_with_limit(&path, Some(200)).unwrap();
561        sink.record(AuditEvent::new("execute").with_command("second"));
562
563        assert!(dir.path().join("audit.jsonl.1").exists());
564    }
565
566    #[test]
567    fn absent_fields_are_omitted_rather_than_null() {
568        let dir = tempfile::tempdir().unwrap();
569        let path = dir.path().join("audit.jsonl");
570        AuditSink::file(&path)
571            .unwrap()
572            .record(AuditEvent::new("execute"));
573
574        let raw = std::fs::read_to_string(&path).unwrap();
575        assert!(!raw.contains("null"), "{raw}");
576    }
577}