Skip to content

Commit aceaf0b

Browse files
committed
Add audit middleware for request logging and include audit module in main.rs
1 parent 7d17026 commit aceaf0b

3 files changed

Lines changed: 128 additions & 0 deletions

File tree

src/app.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use std::{env, path::PathBuf};
44
use axum::{
55
body::{to_bytes, Body},
66
extract::{Path, Request, State},
7+
middleware,
78
http::{header, HeaderMap, HeaderValue, Method, StatusCode, Uri},
89
response::{Html, IntoResponse, Response},
910
routing::{delete, get, post},
@@ -16,6 +17,7 @@ use tokio::fs;
1617
use tower_http::services::ServeDir;
1718

1819
use crate::{
20+
audit,
1921
auth::AdminAuth,
2022
registry::{BotRecord, BotRegistry},
2123
};
@@ -102,6 +104,7 @@ pub fn router(state: Arc<AppState>) -> Router {
102104
.route("/api/bots/{token_hash}", delete(delete_bot))
103105
.nest_service("/assets", ServeDir::new(assets_dir))
104106
.fallback(proxy_or_not_found)
107+
.layer(middleware::from_fn(audit::log_request))
105108
.with_state(state)
106109
}
107110

src/audit.rs

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
use std::time::Instant;
2+
3+
use axum::{
4+
extract::Request,
5+
http::HeaderMap,
6+
middleware::Next,
7+
response::Response,
8+
};
9+
use serde::Serialize;
10+
11+
#[derive(Debug, Serialize)]
12+
struct AuditRecord<'a> {
13+
ts_ms: u128,
14+
method: &'a str,
15+
path: &'a str,
16+
kind: &'a str,
17+
status: u16,
18+
latency_ms: u128,
19+
client_ip: &'a str,
20+
}
21+
22+
pub async fn log_request(request: Request, next: Next) -> Response {
23+
let method = request.method().as_str().to_owned();
24+
let path = redact_path(request.uri().path());
25+
let kind = route_kind(request.uri().path()).to_owned();
26+
let client_ip = client_ip(request.headers()).to_owned();
27+
let started = Instant::now();
28+
29+
let response = next.run(request).await;
30+
let record = AuditRecord {
31+
ts_ms: unix_ms(),
32+
method: &method,
33+
path: &path,
34+
kind: &kind,
35+
status: response.status().as_u16(),
36+
latency_ms: started.elapsed().as_millis(),
37+
client_ip: &client_ip,
38+
};
39+
if let Ok(line) = serde_json::to_string(&record) {
40+
eprintln!("{line}");
41+
}
42+
response
43+
}
44+
45+
fn unix_ms() -> u128 {
46+
std::time::SystemTime::now()
47+
.duration_since(std::time::UNIX_EPOCH)
48+
.map(|duration| duration.as_millis())
49+
.unwrap_or(0)
50+
}
51+
52+
fn route_kind(path: &str) -> &'static str {
53+
if path == "/healthz" {
54+
"health"
55+
} else if path.starts_with("/bot") {
56+
"proxy"
57+
} else if path.starts_with("/api/") {
58+
"api"
59+
} else if path == "/" || path == "/admin" || path.starts_with("/assets/") {
60+
"admin"
61+
} else {
62+
"other"
63+
}
64+
}
65+
66+
fn redact_path(path: &str) -> String {
67+
let Some(rest) = path.strip_prefix("/bot") else {
68+
return path.to_string();
69+
};
70+
71+
let Some((token, method_path)) = rest.split_once('/') else {
72+
return "/bot***".to_string();
73+
};
74+
75+
if token.is_empty() || method_path.is_empty() {
76+
return "/bot***".to_string();
77+
}
78+
79+
format!("/bot***/{method_path}")
80+
}
81+
82+
fn client_ip(headers: &HeaderMap) -> &str {
83+
if let Some(value) = headers.get("x-forwarded-for") {
84+
if let Ok(forwarded) = value.to_str() {
85+
if let Some(ip) = forwarded.split(',').next().map(str::trim).filter(|ip| !ip.is_empty())
86+
{
87+
return ip;
88+
}
89+
}
90+
}
91+
92+
headers
93+
.get("x-real-ip")
94+
.and_then(|value| value.to_str().ok())
95+
.map(str::trim)
96+
.filter(|value| !value.is_empty())
97+
.unwrap_or("-")
98+
}
99+
100+
#[cfg(test)]
101+
mod tests {
102+
use super::*;
103+
104+
#[test]
105+
fn redacts_bot_token_in_proxy_path() {
106+
assert_eq!(
107+
redact_path("/bot123456:ABC/sendMessage"),
108+
"/bot***/sendMessage"
109+
);
110+
}
111+
112+
#[test]
113+
fn leaves_admin_paths_unchanged() {
114+
assert_eq!(redact_path("/api/bots"), "/api/bots");
115+
assert_eq!(redact_path("/api/login"), "/api/login");
116+
}
117+
118+
#[test]
119+
fn classifies_routes() {
120+
assert_eq!(route_kind("/healthz"), "health");
121+
assert_eq!(route_kind("/bot1:ABC/getMe"), "proxy");
122+
assert_eq!(route_kind("/api/login"), "api");
123+
}
124+
}

src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
mod app;
2+
mod audit;
23
mod auth;
34
mod registry;
45

0 commit comments

Comments
 (0)