Skip to main content

nono_proxy/
audit.rs

1//! Audit logging for proxy requests.
2//!
3//! Logs all proxy requests with structured fields via `tracing`.
4//! Sensitive data (authorization headers, tokens, request bodies)
5//! is never included in audit logs.
6
7use nono::undo::{NetworkAuditDecision, NetworkAuditEvent, NetworkAuditMode};
8use std::sync::{Arc, Mutex};
9use std::time::{SystemTime, UNIX_EPOCH};
10use tracing::{info, warn};
11
12/// Maximum number of in-memory network audit events kept per proxy session.
13const MAX_AUDIT_EVENTS: usize = 4096;
14
15/// Shared in-memory sink for network audit events.
16pub type SharedAuditLog = Arc<Mutex<Vec<NetworkAuditEvent>>>;
17
18/// Proxy mode for audit logging.
19#[derive(Debug, Clone, Copy)]
20pub enum ProxyMode {
21    /// CONNECT tunnel (host filtering only)
22    Connect,
23    /// Reverse proxy (credential injection)
24    Reverse,
25    /// External proxy passthrough (enterprise)
26    External,
27}
28
29impl std::fmt::Display for ProxyMode {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        match self {
32            ProxyMode::Connect => write!(f, "connect"),
33            ProxyMode::Reverse => write!(f, "reverse"),
34            ProxyMode::External => write!(f, "external"),
35        }
36    }
37}
38
39/// Create a shared in-memory audit log.
40#[must_use]
41pub fn new_audit_log() -> SharedAuditLog {
42    Arc::new(Mutex::new(Vec::new()))
43}
44
45/// Drain all network audit events collected so far.
46#[must_use]
47pub fn drain_audit_events(audit_log: &SharedAuditLog) -> Vec<NetworkAuditEvent> {
48    match audit_log.lock() {
49        Ok(mut events) => events.drain(..).collect(),
50        Err(e) => {
51            warn!(
52                "Network audit log mutex poisoned while draining events: {}",
53                e
54            );
55            Vec::new()
56        }
57    }
58}
59
60fn now_unix_millis() -> u64 {
61    match SystemTime::now().duration_since(UNIX_EPOCH) {
62        Ok(duration) => {
63            let millis = duration.as_millis();
64            if millis > u128::from(u64::MAX) {
65                warn!("System clock millis exceeded u64::MAX; clamping audit timestamp");
66                u64::MAX
67            } else {
68                millis as u64
69            }
70        }
71        Err(e) => {
72            warn!(
73                "System clock before UNIX_EPOCH while generating audit timestamp: {}",
74                e
75            );
76            0
77        }
78    }
79}
80
81fn map_mode(mode: ProxyMode) -> NetworkAuditMode {
82    match mode {
83        ProxyMode::Connect => NetworkAuditMode::Connect,
84        ProxyMode::Reverse => NetworkAuditMode::Reverse,
85        ProxyMode::External => NetworkAuditMode::External,
86    }
87}
88
89fn push_event(audit_log: Option<&SharedAuditLog>, event: NetworkAuditEvent) {
90    let Some(audit_log) = audit_log else {
91        return;
92    };
93
94    match audit_log.lock() {
95        Ok(mut events) => {
96            if events.len() < MAX_AUDIT_EVENTS {
97                events.push(event);
98            } else {
99                warn!(
100                    "Network audit buffer full ({} events); dropping event",
101                    MAX_AUDIT_EVENTS
102                );
103            }
104        }
105        Err(e) => {
106            warn!(
107                "Network audit log mutex poisoned while recording event: {}",
108                e
109            );
110        }
111    }
112}
113
114/// Log an allowed proxy request.
115pub fn log_allowed(
116    audit_log: Option<&SharedAuditLog>,
117    mode: ProxyMode,
118    host: &str,
119    port: u16,
120    method: &str,
121) {
122    info!(
123        target: "nono_proxy::audit",
124        mode = %mode,
125        host = host,
126        port = port,
127        method = method,
128        decision = "allow",
129        "proxy request allowed"
130    );
131
132    push_event(
133        audit_log,
134        NetworkAuditEvent {
135            timestamp_unix_ms: now_unix_millis(),
136            mode: map_mode(mode),
137            decision: NetworkAuditDecision::Allow,
138            target: host.to_string(),
139            port: Some(port),
140            method: Some(method.to_string()),
141            path: None,
142            status: None,
143            reason: None,
144        },
145    );
146}
147
148/// Log a denied proxy request.
149pub fn log_denied(
150    audit_log: Option<&SharedAuditLog>,
151    mode: ProxyMode,
152    host: &str,
153    port: u16,
154    reason: &str,
155) {
156    info!(
157        target: "nono_proxy::audit",
158        mode = %mode,
159        host = host,
160        port = port,
161        decision = "deny",
162        reason = reason,
163        "proxy request denied"
164    );
165
166    push_event(
167        audit_log,
168        NetworkAuditEvent {
169            timestamp_unix_ms: now_unix_millis(),
170            mode: map_mode(mode),
171            decision: NetworkAuditDecision::Deny,
172            target: host.to_string(),
173            port: Some(port),
174            method: None,
175            path: None,
176            status: None,
177            reason: Some(reason.to_string()),
178        },
179    );
180}
181
182/// Log a reverse proxy request with service info.
183pub fn log_reverse_proxy(
184    audit_log: Option<&SharedAuditLog>,
185    service: &str,
186    method: &str,
187    path: &str,
188    status: u16,
189) {
190    info!(
191        target: "nono_proxy::audit",
192        mode = "reverse",
193        service = service,
194        method = method,
195        path = path,
196        status = status,
197        "reverse proxy response"
198    );
199
200    push_event(
201        audit_log,
202        NetworkAuditEvent {
203            timestamp_unix_ms: now_unix_millis(),
204            mode: NetworkAuditMode::Reverse,
205            decision: NetworkAuditDecision::Allow,
206            target: service.to_string(),
207            port: None,
208            method: Some(method.to_string()),
209            path: Some(path.to_string()),
210            status: Some(status),
211            reason: None,
212        },
213    );
214}
215
216#[cfg(test)]
217#[allow(clippy::unwrap_used)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn log_allowed_records_event() {
223        let log = new_audit_log();
224
225        log_allowed(
226            Some(&log),
227            ProxyMode::Connect,
228            "api.openai.com",
229            443,
230            "CONNECT",
231        );
232
233        let events = drain_audit_events(&log);
234        assert_eq!(events.len(), 1);
235        let event = &events[0];
236        assert_eq!(event.mode, NetworkAuditMode::Connect);
237        assert_eq!(event.decision, NetworkAuditDecision::Allow);
238        assert_eq!(event.target, "api.openai.com");
239        assert_eq!(event.port, Some(443));
240        assert_eq!(event.method.as_deref(), Some("CONNECT"));
241        assert!(event.timestamp_unix_ms > 0);
242    }
243
244    #[test]
245    fn log_denied_records_reason() {
246        let log = new_audit_log();
247
248        log_denied(
249            Some(&log),
250            ProxyMode::External,
251            "169.254.169.254",
252            80,
253            "blocked by metadata deny list",
254        );
255
256        let events = drain_audit_events(&log);
257        assert_eq!(events.len(), 1);
258        let event = &events[0];
259        assert_eq!(event.mode, NetworkAuditMode::External);
260        assert_eq!(event.decision, NetworkAuditDecision::Deny);
261        assert_eq!(
262            event.reason.as_deref(),
263            Some("blocked by metadata deny list")
264        );
265    }
266}