1use nono::undo::{
8 NetworkAuditAuthMechanism, NetworkAuditAuthOutcome, NetworkAuditDecision,
9 NetworkAuditDenialCategory, NetworkAuditEvent, NetworkAuditInjectionMode, NetworkAuditMode,
10};
11use std::sync::{Arc, Mutex};
12use std::time::{SystemTime, UNIX_EPOCH};
13use tracing::{info, warn};
14
15const MAX_AUDIT_EVENTS: usize = 4096;
17
18pub type SharedAuditLog = Arc<Mutex<Vec<NetworkAuditEvent>>>;
20
21#[derive(Debug, Clone, Copy)]
23pub enum ProxyMode {
24 Connect,
26 ConnectIntercept,
29 Reverse,
31 External,
33}
34
35#[derive(Debug, Clone, Default)]
37pub struct EventContext<'a> {
38 pub route_id: Option<&'a str>,
39 pub auth_mechanism: Option<NetworkAuditAuthMechanism>,
40 pub auth_outcome: Option<NetworkAuditAuthOutcome>,
41 pub managed_credential_active: Option<bool>,
42 pub injection_mode: Option<NetworkAuditInjectionMode>,
43 pub denial_category: Option<NetworkAuditDenialCategory>,
44}
45
46impl std::fmt::Display for ProxyMode {
47 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48 match self {
49 ProxyMode::Connect => write!(f, "connect"),
50 ProxyMode::ConnectIntercept => write!(f, "connect_intercept"),
51 ProxyMode::Reverse => write!(f, "reverse"),
52 ProxyMode::External => write!(f, "external"),
53 }
54 }
55}
56
57#[must_use]
59pub fn new_audit_log() -> SharedAuditLog {
60 Arc::new(Mutex::new(Vec::new()))
61}
62
63#[must_use]
65pub fn drain_audit_events(audit_log: &SharedAuditLog) -> Vec<NetworkAuditEvent> {
66 match audit_log.lock() {
67 Ok(mut events) => events.drain(..).collect(),
68 Err(e) => {
69 warn!(
70 "Network audit log mutex poisoned while draining events: {}",
71 e
72 );
73 Vec::new()
74 }
75 }
76}
77
78fn now_unix_millis() -> u64 {
79 match SystemTime::now().duration_since(UNIX_EPOCH) {
80 Ok(duration) => {
81 let millis = duration.as_millis();
82 if millis > u128::from(u64::MAX) {
83 warn!("System clock millis exceeded u64::MAX; clamping audit timestamp");
84 u64::MAX
85 } else {
86 millis as u64
87 }
88 }
89 Err(e) => {
90 warn!(
91 "System clock before UNIX_EPOCH while generating audit timestamp: {}",
92 e
93 );
94 0
95 }
96 }
97}
98
99fn map_mode(mode: ProxyMode) -> NetworkAuditMode {
100 match mode {
101 ProxyMode::Connect => NetworkAuditMode::Connect,
102 ProxyMode::ConnectIntercept => NetworkAuditMode::ConnectIntercept,
103 ProxyMode::Reverse => NetworkAuditMode::Reverse,
104 ProxyMode::External => NetworkAuditMode::External,
105 }
106}
107
108fn push_event(audit_log: Option<&SharedAuditLog>, event: NetworkAuditEvent) {
109 let Some(audit_log) = audit_log else {
110 return;
111 };
112
113 match audit_log.lock() {
114 Ok(mut events) => {
115 if events.len() < MAX_AUDIT_EVENTS {
116 events.push(event);
117 } else {
118 warn!(
119 "Network audit buffer full ({} events); dropping event",
120 MAX_AUDIT_EVENTS
121 );
122 }
123 }
124 Err(e) => {
125 warn!(
126 "Network audit log mutex poisoned while recording event: {}",
127 e
128 );
129 }
130 }
131}
132
133pub fn log_allowed(
135 audit_log: Option<&SharedAuditLog>,
136 mode: ProxyMode,
137 ctx: &EventContext<'_>,
138 host: &str,
139 port: u16,
140 method: &str,
141) {
142 info!(
143 target: "nono_proxy::audit",
144 mode = %mode,
145 host = host,
146 port = port,
147 method = method,
148 decision = "allow",
149 "proxy request allowed"
150 );
151
152 push_event(
153 audit_log,
154 NetworkAuditEvent {
155 timestamp_unix_ms: now_unix_millis(),
156 mode: map_mode(mode),
157 decision: NetworkAuditDecision::Allow,
158 route_id: ctx.route_id.map(str::to_string),
159 auth_mechanism: ctx.auth_mechanism.clone(),
160 auth_outcome: ctx.auth_outcome.clone(),
161 managed_credential_active: ctx.managed_credential_active,
162 injection_mode: ctx.injection_mode.clone(),
163 denial_category: None,
164 target: host.to_string(),
165 port: Some(port),
166 method: Some(method.to_string()),
167 path: None,
168 status: None,
169 reason: None,
170 },
171 );
172}
173
174pub fn log_denied(
176 audit_log: Option<&SharedAuditLog>,
177 mode: ProxyMode,
178 ctx: &EventContext<'_>,
179 host: &str,
180 port: u16,
181 reason: &str,
182) {
183 info!(
184 target: "nono_proxy::audit",
185 mode = %mode,
186 host = host,
187 port = port,
188 decision = "deny",
189 reason = reason,
190 "proxy request denied"
191 );
192
193 push_event(
194 audit_log,
195 NetworkAuditEvent {
196 timestamp_unix_ms: now_unix_millis(),
197 mode: map_mode(mode),
198 decision: NetworkAuditDecision::Deny,
199 route_id: ctx.route_id.map(str::to_string),
200 auth_mechanism: ctx.auth_mechanism.clone(),
201 auth_outcome: ctx.auth_outcome.clone(),
202 managed_credential_active: ctx.managed_credential_active,
203 injection_mode: ctx.injection_mode.clone(),
204 denial_category: ctx.denial_category.clone(),
205 target: host.to_string(),
206 port: Some(port),
207 method: None,
208 path: None,
209 status: None,
210 reason: Some(reason.to_string()),
211 },
212 );
213}
214
215pub fn log_l7_request(
221 audit_log: Option<&SharedAuditLog>,
222 mode: ProxyMode,
223 ctx: &EventContext<'_>,
224 target: &str,
225 method: &str,
226 path: &str,
227 status: u16,
228) {
229 info!(
230 target: "nono_proxy::audit",
231 mode = %mode,
232 target = target,
233 method = method,
234 path = path,
235 status = status,
236 "l7 proxy response"
237 );
238
239 push_event(
240 audit_log,
241 NetworkAuditEvent {
242 timestamp_unix_ms: now_unix_millis(),
243 mode: map_mode(mode),
244 decision: NetworkAuditDecision::Allow,
245 route_id: ctx.route_id.map(str::to_string),
246 auth_mechanism: ctx.auth_mechanism.clone(),
247 auth_outcome: ctx.auth_outcome.clone(),
248 managed_credential_active: ctx.managed_credential_active,
249 injection_mode: ctx.injection_mode.clone(),
250 denial_category: None,
251 target: target.to_string(),
252 port: None,
253 method: Some(method.to_string()),
254 path: Some(path.to_string()),
255 status: Some(status),
256 reason: None,
257 },
258 );
259}
260
261#[deprecated(since = "0.46.0", note = "use log_l7_request with ProxyMode::Reverse")]
265pub fn log_reverse_proxy(
266 audit_log: Option<&SharedAuditLog>,
267 service: &str,
268 method: &str,
269 path: &str,
270 status: u16,
271) {
272 log_l7_request(
273 audit_log,
274 ProxyMode::Reverse,
275 &EventContext {
276 route_id: Some(service),
277 ..EventContext::default()
278 },
279 service,
280 method,
281 path,
282 status,
283 );
284}
285
286#[cfg(test)]
287#[allow(clippy::unwrap_used)]
288mod tests {
289 use super::*;
290
291 #[test]
292 fn log_allowed_records_event() {
293 let log = new_audit_log();
294
295 log_allowed(
296 Some(&log),
297 ProxyMode::Connect,
298 &EventContext::default(),
299 "api.openai.com",
300 443,
301 "CONNECT",
302 );
303
304 let events = drain_audit_events(&log);
305 assert_eq!(events.len(), 1);
306 let event = &events[0];
307 assert_eq!(event.mode, NetworkAuditMode::Connect);
308 assert_eq!(event.decision, NetworkAuditDecision::Allow);
309 assert_eq!(event.route_id, None);
310 assert_eq!(event.auth_mechanism, None);
311 assert_eq!(event.target, "api.openai.com");
312 assert_eq!(event.port, Some(443));
313 assert_eq!(event.method.as_deref(), Some("CONNECT"));
314 assert!(event.timestamp_unix_ms > 0);
315 }
316
317 #[test]
318 fn log_denied_records_reason() {
319 let log = new_audit_log();
320
321 log_denied(
322 Some(&log),
323 ProxyMode::External,
324 &EventContext::default(),
325 "169.254.169.254",
326 80,
327 "blocked by metadata deny list",
328 );
329
330 let events = drain_audit_events(&log);
331 assert_eq!(events.len(), 1);
332 let event = &events[0];
333 assert_eq!(event.mode, NetworkAuditMode::External);
334 assert_eq!(event.decision, NetworkAuditDecision::Deny);
335 assert_eq!(event.route_id, None);
336 assert_eq!(event.auth_mechanism, None);
337 assert_eq!(
338 event.reason.as_deref(),
339 Some("blocked by metadata deny list")
340 );
341 }
342}