Skip to content

Commit d72d812

Browse files
committed
feat(axum): optional extraction of client.address (former client_ip) from http headers or socket's info
FIX #68 #258
1 parent cabaf01 commit d72d812

File tree

3 files changed

+137
-26
lines changed

3 files changed

+137
-26
lines changed

axum-tracing-opentelemetry/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ repository.workspace = true
1717
license.workspace = true
1818

1919
[dependencies]
20-
axum = { workspace = true, features = ["matched-path"] }
20+
axum = { workspace = true, features = ["matched-path", "tokio"] }
2121
futures-core = "0.3"
2222
futures-util = { version = "0.3", default-features = false, features = [] }
2323
http = { workspace = true }

axum-tracing-opentelemetry/src/middleware/trace_extractor.rs

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -32,18 +32,21 @@
3232
//! ```
3333
//!
3434
35-
use axum::extract::MatchedPath;
35+
use axum::extract::{ConnectInfo, MatchedPath};
3636
use http::{Request, Response};
3737
use pin_project_lite::pin_project;
3838
use std::{
3939
error::Error,
4040
future::Future,
41+
net::SocketAddr,
4142
pin::Pin,
4243
task::{Context, Poll},
4344
};
4445
use tower::{Layer, Service};
4546
use tracing::Span;
46-
use tracing_opentelemetry_instrumentation_sdk::http as otel_http;
47+
use tracing_opentelemetry_instrumentation_sdk::http::{
48+
self as otel_http, extract_client_ip_from_headers,
49+
};
4750

4851
#[deprecated(
4952
since = "0.12.0",
@@ -65,15 +68,35 @@ pub type Filter = fn(&str) -> bool;
6568
#[derive(Default, Debug, Clone)]
6669
pub struct OtelAxumLayer {
6770
filter: Option<Filter>,
71+
try_extract_client_ip: bool,
6872
}
6973

7074
// add a builder like api
7175
impl OtelAxumLayer {
7276
#[must_use]
7377
pub fn filter(self, filter: Filter) -> Self {
74-
OtelAxumLayer {
75-
filter: Some(filter),
76-
}
78+
let mut me = self;
79+
me.filter = Some(filter);
80+
me
81+
}
82+
83+
/// Enable or disable (default) the extraction of client's ip.
84+
/// Extraction from (in order):
85+
///
86+
/// 1. http header 'Forwarded'
87+
/// 2. http header `X-Forwarded-For`
88+
/// 3. socket connection ip, use the `axum::extract::ConnectionInfo` (see [`Router::into_make_service_with_connect_info`] for more details)
89+
/// 4. empty (failed to extract the information)
90+
///
91+
/// The extracted value could an ip v4, ip v6, a string (as `Forwarded` can use label or hide the client).
92+
/// The extracted value is stored it as `client.address` in the span/trace
93+
///
94+
/// [`Router::into_make_service_with_connect_info`]: axum::routing::Router::into_make_service_with_connect_info
95+
#[must_use]
96+
pub fn try_extract_client_ip(self, enable: bool) -> Self {
97+
let mut me = self;
98+
me.try_extract_client_ip = enable;
99+
me
77100
}
78101
}
79102

@@ -84,6 +107,7 @@ impl<S> Layer<S> for OtelAxumLayer {
84107
OtelAxumService {
85108
inner,
86109
filter: self.filter,
110+
try_extract_client_ip: self.try_extract_client_ip,
87111
}
88112
}
89113
}
@@ -92,6 +116,7 @@ impl<S> Layer<S> for OtelAxumLayer {
92116
pub struct OtelAxumService<S> {
93117
inner: S,
94118
filter: Option<Filter>,
119+
try_extract_client_ip: bool,
95120
}
96121

97122
impl<S, B, B2> Service<Request<B>> for OtelAxumService<S>
@@ -115,20 +140,26 @@ where
115140
use tracing_opentelemetry::OpenTelemetrySpanExt;
116141
let req = req;
117142
let span = if self.filter.map_or(true, |f| f(req.uri().path())) {
118-
let span = otel_http::http_server::make_span_from_request(&req);
119143
let route = http_route(&req);
120144
let method = otel_http::http_method(req.method());
121-
// let client_ip = parse_x_forwarded_for(req.headers())
122-
// .or_else(|| {
123-
// req.extensions()
124-
// .get::<ConnectInfo<SocketAddr>>()
125-
// .map(|ConnectInfo(client_ip)| Cow::from(client_ip.to_string()))
126-
// })
127-
// .unwrap_or_default();
145+
let client_ip = if self.try_extract_client_ip {
146+
extract_client_ip_from_headers(req.headers())
147+
.map(ToString::to_string)
148+
.or_else(|| {
149+
req.extensions()
150+
.get::<ConnectInfo<SocketAddr>>()
151+
.map(|ConnectInfo(client_ip)| client_ip.to_string())
152+
})
153+
} else {
154+
None
155+
};
156+
157+
let span = otel_http::http_server::make_span_from_request(&req);
128158
span.record("http.route", route);
129159
span.record("otel.name", format!("{method} {route}").trim());
130-
// span.record("trace_id", find_trace_id_from_tracing(&span));
131-
// span.record("client.address", client_ip);
160+
if let Some(client_ip) = client_ip {
161+
span.record("client.address", client_ip);
162+
}
132163
span.set_parent(otel_http::extract_context(req.headers()));
133164
span
134165
} else {

tracing-opentelemetry-instrumentation-sdk/src/http/tools.rs

Lines changed: 90 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,22 +27,49 @@ pub fn extract_service_method(uri: &Uri) -> (&str, &str) {
2727
(service, method)
2828
}
2929

30-
fn parse_x_forwarded_for(headers: &HeaderMap) -> Option<&str> {
30+
#[must_use]
31+
// From [X-Forwarded-For - HTTP | MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For)
32+
// > If a request goes through multiple proxies, the IP addresses of each successive proxy is listed.
33+
// > This means that, given well-behaved client and proxies,
34+
// > the rightmost IP address is the IP address of the most recent proxy and
35+
// > the leftmost IP address is the IP address of the originating client.
36+
pub fn extract_client_ip_from_headers(headers: &HeaderMap) -> Option<&str> {
37+
extract_client_ip_from_forwarded(headers)
38+
.or_else(|| extract_client_ip_from_x_forwarded_for(headers))
39+
}
40+
41+
#[must_use]
42+
// From [X-Forwarded-For - HTTP | MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For)
43+
// > If a request goes through multiple proxies, the IP addresses of each successive proxy is listed.
44+
// > This means that, given well-behaved client and proxies,
45+
// > the rightmost IP address is the IP address of the most recent proxy and
46+
// > the leftmost IP address is the IP address of the originating client.
47+
fn extract_client_ip_from_x_forwarded_for(headers: &HeaderMap) -> Option<&str> {
3148
let value = headers.get("x-forwarded-for")?;
3249
let value = value.to_str().ok()?;
3350
let mut ips = value.split(',');
3451
Some(ips.next()?.trim())
3552
}
3653

37-
#[inline]
38-
pub fn client_ip<B>(req: &http::Request<B>) -> &str {
39-
parse_x_forwarded_for(req.headers())
40-
// .or_else(|| {
41-
// req.extensions()
42-
// .get::<ConnectInfo<SocketAddr>>()
43-
// .map(|ConnectInfo(client_ip)| Cow::from(client_ip.to_string()))
44-
// })
45-
.unwrap_or_default()
54+
#[must_use]
55+
// see [Forwarded header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Forwarded)
56+
fn extract_client_ip_from_forwarded(headers: &HeaderMap) -> Option<&str> {
57+
let value = headers.get("forwarded")?;
58+
let value = value.to_str().ok()?;
59+
value
60+
.split(';')
61+
.flat_map(|directive| directive.split(','))
62+
// select the left/first "for" key
63+
.find_map(|directive| directive.trim().strip_prefix("for="))
64+
// ipv6 are enclosed into `["..."]`
65+
// string are enclosed into `"..."`
66+
.map(|directive| {
67+
directive
68+
.trim_start_matches('[')
69+
.trim_end_matches(']')
70+
.trim_matches('"')
71+
.trim()
72+
})
4673
}
4774

4875
#[inline]
@@ -128,4 +155,57 @@ mod tests {
128155
let uri: Uri = input.parse().unwrap();
129156
assert!(url_scheme(&uri) == expected);
130157
}
158+
159+
#[rstest]
160+
#[case("", "")]
161+
#[case(
162+
"2001:db8:85a3:8d3:1319:8a2e:370:7348",
163+
"2001:db8:85a3:8d3:1319:8a2e:370:7348"
164+
)]
165+
#[case("203.0.113.195", "203.0.113.195")]
166+
#[case("203.0.113.195,10.10.10.10", "203.0.113.195")]
167+
#[case("203.0.113.195, 2001:db8:85a3:8d3:1319:8a2e:370:7348", "203.0.113.195")]
168+
fn test_extract_client_ip_from_x_forwarded_for(#[case] input: &str, #[case] expected: &str) {
169+
let mut headers = HeaderMap::new();
170+
if !input.is_empty() {
171+
headers.insert("X-Forwarded-For", input.parse().unwrap());
172+
}
173+
174+
let expected = if expected.is_empty() {
175+
None
176+
} else {
177+
Some(expected)
178+
};
179+
assert!(extract_client_ip_from_x_forwarded_for(&headers) == expected);
180+
}
181+
182+
#[rstest]
183+
#[case("", "")]
184+
#[case(
185+
"for=[\"2001:db8:85a3:8d3:1319:8a2e:370:7348\"]",
186+
"2001:db8:85a3:8d3:1319:8a2e:370:7348"
187+
)]
188+
#[case("for=203.0.113.195", "203.0.113.195")]
189+
#[case("for=203.0.113.195, for=10.10.10.10", "203.0.113.195")]
190+
#[case(
191+
"for=203.0.113.195, for=[\"2001:db8:85a3:8d3:1319:8a2e:370:7348\"]",
192+
"203.0.113.195"
193+
)]
194+
#[case("for=\"_mdn\"", "_mdn")]
195+
#[case("for=\"secret\"", "secret")]
196+
#[case("for=203.0.113.195;proto=http;by=203.0.113.43", "203.0.113.195")]
197+
#[case("proto=http;by=203.0.113.43", "")]
198+
fn test_extract_client_ip_from_forwarded(#[case] input: &str, #[case] expected: &str) {
199+
let mut headers = HeaderMap::new();
200+
if !input.is_empty() {
201+
headers.insert("Forwarded", input.parse().unwrap());
202+
}
203+
204+
let expected = if expected.is_empty() {
205+
None
206+
} else {
207+
Some(expected)
208+
};
209+
assert!(extract_client_ip_from_forwarded(&headers) == expected);
210+
}
131211
}

0 commit comments

Comments
 (0)