1#![expect(
6 clippy::allow_attributes,
7 reason = "feature-gated `mut self` consumed by some cfg branches but not others β `#[allow(unused_mut)]` would warn unfulfilled in the cfg arm where it IS used"
8)]
9
10use crate::{
11 Layer, Service,
12 cli::ForwardKind,
13 combinators::Either,
14 combinators::Either7,
15 error::{BoxError, BoxErrorExt, ErrorExt as _},
16 extensions::ExtensionsRef,
17 http::BodyLimitLayer,
18 http::{
19 Request, Response, StatusCode,
20 headers::exotic::XClacksOverhead,
21 headers::forwarded::{CFConnectingIp, ClientIp, TrueClientIp, XClientIp, XRealIp},
22 headers::{Accept, HeaderMapExt},
23 layer::{
24 forwarded::GetForwardedHeaderLayer, required_header::AddRequiredResponseHeadersLayer,
25 set_header::SetResponseHeaderLayer, trace::TraceLayer,
26 },
27 mime,
28 server::HttpServer,
29 service::web::response::{Css, IntoResponse, Json, Redirect, Script},
30 },
31 io::Io,
32 layer::limit::policy::UnlimitedPolicy,
33 layer::{
34 ConsumeErrLayer, LimitLayer, TimeoutLayer,
35 limit::policy::{ConcurrentPolicy, RateLimitReached, RatePolicy},
36 },
37 net::address::ip::geo::{GeoLocation, IpGeoDb, IpGeoInfo},
38 net::forwarded::Forwarded,
39 net::stream::SocketInfo,
40 net::stream::layer::{ThrottleLayer, ThrottleMode},
41 proxy::haproxy::server::HaProxyLayer,
42 rt::Executor,
43 tcp::TcpStream,
44 telemetry::tracing,
45 utils::{octets::mib, rate::Rate},
46};
47
48use std::{convert::Infallible, marker::PhantomData, net::IpAddr, sync::Arc, time::Duration};
49use tokio::io::AsyncWriteExt;
50
51core::cfg_select! {
52 feature = "boring" => {
53 use crate::tls::boring::server::TlsAcceptorLayer;
54 }
55 feature = "rustls" => {
56 use crate::tls::rustls::server::TlsAcceptorLayer;
57 }
58 _ => {}
59}
60
61#[cfg(any(feature = "rustls", feature = "boring"))]
62use crate::{http::headers::StrictTransportSecurity, tls::server::TlsServerConfig};
63
64#[derive(Debug, Clone)]
65pub struct IpServiceBuilder<M> {
68 #[cfg(any(feature = "rustls", feature = "boring"))]
69 tls_server_config: Option<TlsServerConfig>,
70 concurrent_limit: usize,
71 rate_limit: Option<Rate>,
72 throttle: Option<Rate>,
73 timeout: Duration,
74 forward: Option<ForwardKind>,
75 geo_db: Option<Arc<IpGeoDb>>,
76 _mode: PhantomData<fn(M)>,
77}
78
79impl IpServiceBuilder<mode::Http> {
80 #[must_use]
82 pub fn http() -> Self {
83 Self {
84 #[cfg(any(feature = "rustls", feature = "boring"))]
85 tls_server_config: None,
86 concurrent_limit: 0,
87 rate_limit: None,
88 throttle: None,
89 timeout: Duration::ZERO,
90 forward: None,
91 geo_db: None,
92 _mode: PhantomData,
93 }
94 }
95}
96
97impl IpServiceBuilder<mode::Transport> {
98 #[must_use]
100 pub fn tcp() -> Self {
101 Self {
102 #[cfg(any(feature = "rustls", feature = "boring"))]
103 tls_server_config: None,
104 concurrent_limit: 0,
105 rate_limit: None,
106 throttle: None,
107 timeout: Duration::ZERO,
108 forward: None,
109 geo_db: None,
110 _mode: PhantomData,
111 }
112 }
113}
114
115impl<M> IpServiceBuilder<M> {
116 crate::utils::macros::generate_set_and_with! {
117 #[must_use]
119 pub fn concurrent(mut self, limit: usize) -> Self {
120 self.concurrent_limit = limit;
121 self
122 }
123 }
124
125 crate::utils::macros::generate_set_and_with! {
126 #[must_use]
130 pub fn rate_limit(mut self, rate: Option<Rate>) -> Self {
131 self.rate_limit = rate;
132 self
133 }
134 }
135
136 crate::utils::macros::generate_set_and_with! {
137 #[must_use]
140 pub fn throttle(mut self, rate: Option<Rate>) -> Self {
141 self.throttle = rate;
142 self
143 }
144 }
145
146 crate::utils::macros::generate_set_and_with! {
147 #[must_use]
149 pub fn timeout(mut self, timeout: Duration) -> Self {
150 self.timeout = timeout;
151 self
152 }
153 }
154
155 crate::utils::macros::generate_set_and_with! {
156 #[must_use]
168 pub fn forward(mut self, maybe_kind: Option<ForwardKind>) -> Self {
169 self.forward = maybe_kind;
170 self
171 }
172 }
173
174 crate::utils::macros::generate_set_and_with! {
175 #[must_use]
178 pub fn geo_db(mut self, db: Option<Arc<IpGeoDb>>) -> Self {
179 self.geo_db = db;
180 self
181 }
182 }
183
184 crate::utils::macros::generate_set_and_with! {
185 #[cfg(any(feature = "rustls", feature = "boring"))]
186 pub fn tls_server_config(mut self, cfg: Option<TlsServerConfig>) -> Self {
189 self.tls_server_config = cfg;
190 self
191 }
192 }
193}
194
195impl IpServiceBuilder<mode::Http> {
196 #[allow(unused_mut)]
197 #[inline]
198 pub fn build(
200 mut self,
201 executor: Executor,
202 ) -> Result<impl Service<TcpStream, Output = (), Error = Infallible>, BoxError> {
203 #[cfg(any(feature = "rustls", feature = "boring"))]
204 {
205 let maybe_tls_acceptor_layer = self.tls_server_config.take().map(TlsAcceptorLayer::new);
206 self.build_http(executor, maybe_tls_acceptor_layer)
207 }
208
209 #[cfg(not(any(feature = "rustls", feature = "boring")))]
210 self.build_http(executor)
211 }
212}
213
214#[derive(Debug, Clone)]
215struct HttpIpService {
220 geo_db: Option<Arc<IpGeoDb>>,
223}
224
225impl Service<Request> for HttpIpService {
226 type Output = Response;
227 type Error = Infallible;
228
229 async fn serve(&self, req: Request) -> Result<Self::Output, Self::Error> {
230 let peer_ip = req
231 .extensions()
232 .get_ref::<Forwarded>()
233 .and_then(|f| f.client_ip())
234 .or_else(|| {
235 req.extensions()
236 .get_ref::<SocketInfo>()
237 .map(|s| s.peer_addr().ip_addr)
238 });
239
240 Ok(match peer_ip {
241 Some(ip) => match HttpBodyContentFormat::derive_from_req(&req) {
242 HttpBodyContentFormat::Txt => ip.to_string().into_response(),
243 HttpBodyContentFormat::Html => {
244 let geo = self.geo_db.as_ref().and_then(|db| db.resolve(ip));
245 let attributions: Vec<_> = self
246 .geo_db
247 .as_ref()
248 .map(|db| db.attributions().collect())
249 .unwrap_or_default();
250 render_html_page(ip, geo.as_ref(), &attributions).into_response()
251 }
252 HttpBodyContentFormat::Json => {
253 let geo = self.geo_db.as_ref().and_then(|db| db.resolve(ip));
254 let mut body = serde_json::json!({ "ip": ip });
255 if let Some(info) = geo {
256 body["geo"] = serde_json::to_value(&info).unwrap_or_default();
258 }
259 Json(body).into_response()
260 }
261 },
262 None => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
263 })
264 }
265}
266
267const IP_STYLE_CSS: &str = include_str!("ip.css");
271
272const IP_SCRIPT_JS: &str = include_str!("ip.js");
275
276#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
277enum HttpBodyContentFormat {
278 #[default]
279 Txt,
280 Html,
281 Json,
282}
283
284impl HttpBodyContentFormat {
285 fn derive_from_req(req: &Request) -> Self {
286 let Some(accept) = req.headers().typed_get::<Accept>() else {
287 return Self::default();
288 };
289 let mut entries: Vec<_> = accept.0.iter().collect();
292 entries.sort_by_key(|qv| std::cmp::Reverse(qv.quality));
293 entries
294 .into_iter()
295 .find_map(|qv| {
296 let r#type = qv.value.subtype();
297 if r#type == mime::JSON {
298 Some(Self::Json)
299 } else if r#type == mime::HTML {
300 Some(Self::Html)
301 } else if r#type == mime::TEXT {
302 Some(Self::Txt)
303 } else {
304 None
305 }
306 })
307 .unwrap_or_default()
308 }
309}
310
311#[derive(Debug, Clone)]
312#[non_exhaustive]
313struct TcpIpService;
315
316impl<Input> Service<Input> for TcpIpService
317where
318 Input: Io + Unpin + ExtensionsRef,
319{
320 type Output = ();
321 type Error = BoxError;
322
323 async fn serve(&self, stream: Input) -> Result<Self::Output, Self::Error> {
324 tracing::info!("connection received");
325 let peer_ip = stream
326 .extensions()
327 .get_ref::<Forwarded>()
328 .and_then(|f| f.client_ip())
329 .or_else(|| {
330 stream
331 .extensions()
332 .get_ref::<SocketInfo>()
333 .map(|s| s.peer_addr().ip_addr)
334 });
335 let Some(peer_ip) = peer_ip else {
336 tracing::error!("missing peer information");
337 return Ok(());
338 };
339
340 let mut stream = std::pin::pin!(stream);
341
342 match peer_ip {
343 std::net::IpAddr::V4(ip) => {
344 if let Err(err) = stream.write_all(&ip.octets()).await {
345 tracing::error!("error writing IPv4 of peer to peer: {}", err);
346 }
347 }
348 std::net::IpAddr::V6(ip) => {
349 if let Err(err) = stream.write_all(&ip.octets()).await {
350 tracing::error!("error writing IPv6 of peer to peer: {}", err);
351 }
352 }
353 };
354
355 Ok(())
356 }
357}
358
359impl IpServiceBuilder<mode::Transport> {
360 #[allow(unused_mut)]
361 #[inline]
362 pub fn build(
364 mut self,
365 ) -> Result<impl Service<TcpStream, Output = (), Error = Infallible>, BoxError> {
366 #[cfg(any(feature = "rustls", feature = "boring"))]
367 {
368 let maybe_tls_acceptor_layer = self.tls_server_config.take().map(TlsAcceptorLayer::new);
369 self.build_tcp(maybe_tls_acceptor_layer)
370 }
371
372 #[cfg(not(any(feature = "rustls", feature = "boring")))]
373 self.build_tcp()
374 }
375}
376
377impl<M> IpServiceBuilder<M> {
378 fn build_tcp<S: Io + ExtensionsRef + Unpin + Sync>(
379 self,
380 #[cfg(any(feature = "rustls", feature = "boring"))] maybe_tls_accept_layer: Option<
381 TlsAcceptorLayer,
382 >,
383 ) -> Result<impl Service<S, Output = (), Error = Infallible>, BoxError> {
384 let tcp_forwarded_layer = match &self.forward {
385 None => None,
386 Some(ForwardKind::HaProxy) => Some(HaProxyLayer::default()),
387 Some(other) => {
388 return Err(
389 BoxError::from_static_str("invalid forward kind for Transport mode")
390 .with_context_debug_field("kind", || other.clone()),
391 );
392 }
393 };
394
395 let tcp_service_builder = (
396 ConsumeErrLayer::trace_as(tracing::Level::DEBUG),
397 self.rate_limit
398 .map(|rate| LimitLayer::new(RatePolicy::abort(rate))),
399 LimitLayer::new(if self.concurrent_limit > 0 {
400 Either::A(ConcurrentPolicy::max(self.concurrent_limit))
401 } else {
402 Either::B(UnlimitedPolicy::new())
403 }),
404 if !self.timeout.is_zero() {
405 TimeoutLayer::new(self.timeout)
406 } else {
407 TimeoutLayer::never()
408 },
409 self.throttle
410 .map(|rate| ThrottleLayer::symmetric(ThrottleMode::per_conn(rate))),
411 tcp_forwarded_layer,
412 #[cfg(any(feature = "rustls", feature = "boring"))]
413 maybe_tls_accept_layer,
414 );
415
416 Ok(tcp_service_builder.into_layer(TcpIpService))
417 }
418
419 fn build_http<S: Io + Unpin + Sync + ExtensionsRef>(
420 self,
421 executor: Executor,
422 #[cfg(any(feature = "rustls", feature = "boring"))] maybe_tls_accept_layer: Option<
423 TlsAcceptorLayer,
424 >,
425 ) -> Result<impl Service<S, Output = (), Error = Infallible>, BoxError> {
426 let (tcp_forwarded_layer, http_forwarded_layer) = match &self.forward {
427 None => (None, None),
428 Some(ForwardKind::Forwarded) => {
429 (None, Some(Either7::A(GetForwardedHeaderLayer::forwarded())))
430 }
431 Some(ForwardKind::XForwardedFor) => (
432 None,
433 Some(Either7::B(GetForwardedHeaderLayer::x_forwarded_for())),
434 ),
435 Some(ForwardKind::XClientIp) => (
436 None,
437 Some(Either7::C(GetForwardedHeaderLayer::<XClientIp>::new())),
438 ),
439 Some(ForwardKind::ClientIp) => (
440 None,
441 Some(Either7::D(GetForwardedHeaderLayer::<ClientIp>::new())),
442 ),
443 Some(ForwardKind::XRealIp) => (
444 None,
445 Some(Either7::E(GetForwardedHeaderLayer::<XRealIp>::new())),
446 ),
447 Some(ForwardKind::CFConnectingIp) => (
448 None,
449 Some(Either7::F(GetForwardedHeaderLayer::<CFConnectingIp>::new())),
450 ),
451 Some(ForwardKind::TrueClientIp) => (
452 None,
453 Some(Either7::G(GetForwardedHeaderLayer::<TrueClientIp>::new())),
454 ),
455 Some(ForwardKind::HaProxy) => (Some(HaProxyLayer::default()), None),
456 };
457
458 #[cfg(any(feature = "rustls", feature = "boring"))]
459 let hsts_layer = maybe_tls_accept_layer.is_some().then(|| {
460 SetResponseHeaderLayer::if_not_present_typed(
461 StrictTransportSecurity::excluding_subdomains_for_max_seconds(31536000),
462 )
463 });
464
465 let tcp_service_builder = (
466 ConsumeErrLayer::trace_as(tracing::Level::DEBUG),
467 (self.concurrent_limit > 0)
468 .then(|| LimitLayer::new(ConcurrentPolicy::max(self.concurrent_limit))),
469 (!self.timeout.is_zero()).then(|| TimeoutLayer::new(self.timeout)),
470 self.throttle
471 .map(|rate| ThrottleLayer::symmetric(ThrottleMode::per_conn(rate))),
472 tcp_forwarded_layer,
473 BodyLimitLayer::request_only(mib(1)),
475 #[cfg(any(feature = "rustls", feature = "boring"))]
476 maybe_tls_accept_layer,
477 );
478
479 let (csp_layer, nosniff_layer, referrer_layer, frame_layer) =
487 crate::cli::service::http_security::defence_in_depth_layer(
488 crate::cli::service::http_security::rama_html_csp(),
489 );
490
491 let geo_attribution = self.geo_db.as_ref().and_then(|db| {
493 let notices: Vec<_> = db.attributions().collect();
494 (!notices.is_empty()).then(|| crate::cli::service::geo::geo_attribution_layer(notices))
495 });
496
497 let router = crate::http::service::web::Router::new()
501 .with_get(
502 "/",
503 HttpIpService {
504 geo_db: self.geo_db,
505 },
506 )
507 .with_get("/style/ip.css", Css(IP_STYLE_CSS))
508 .with_get("/script/ip.js", Script(IP_SCRIPT_JS))
509 .with_not_found(async || Redirect::permanent("/"));
510
511 let http_service = (
512 TraceLayer::new_for_http(),
513 SetResponseHeaderLayer::<XClacksOverhead>::if_not_present_default_typed(),
514 AddRequiredResponseHeadersLayer::default(),
515 self.rate_limit.map(|rate| {
516 LimitLayer::new(RatePolicy::abort(rate)).with_error_into_response_fn(
517 |err: RateLimitReached| Ok::<_, Infallible>(err.into_response()),
518 )
519 }),
520 geo_attribution,
521 csp_layer,
522 nosniff_layer,
523 referrer_layer,
524 frame_layer,
525 ConsumeErrLayer::default(),
526 #[cfg(any(feature = "rustls", feature = "boring"))]
527 hsts_layer,
528 http_forwarded_layer,
529 )
530 .into_layer(router);
531
532 let http_service = Arc::new(http_service);
536 Ok(tcp_service_builder.into_layer(HttpServer::auto(executor).service(http_service)))
537 }
538}
539
540pub mod mode {
541 #[derive(Debug, Clone)]
544 #[non_exhaustive]
545 pub struct Http;
547
548 #[derive(Debug, Clone)]
549 #[non_exhaustive]
550 pub struct Transport;
552}
553
554fn render_html_page(
555 ip: IpAddr,
556 geo: Option<&IpGeoInfo>,
557 attributions: &[&str],
558) -> impl crate::http::protocols::html::IntoHtml + IntoResponse {
559 use crate::http::protocols::html::*;
560
561 let geo_comment =
563 crate::cli::service::geo::geo_attribution_html_comment(attributions).map(PreEscaped);
564 let geo_panel = geo.map(|info| {
565 let rows = |loc: &GeoLocation| {
566 crate::cli::service::geo::geo_location_rows(loc)
567 .into_iter()
568 .map(|(k, v)| div!(class = "georow", div!(class = "muted", k), div!(code!(v))))
569 .collect::<Vec<_>>()
570 };
571 let card = |label: String, loc: &GeoLocation| {
573 div!(
574 class = "panel geo-card",
575 div!(class = "muted geo-source", label),
576 rows(loc),
577 )
578 };
579 let mut cards = vec![card("merged".to_owned(), &info.location)];
580 cards.extend(
581 info.by_source
582 .iter()
583 .map(|src| card(src.label.to_string(), &src.location)),
584 );
585 div!(
586 class = "geo-section",
587 role = "region",
588 "aria-label" = "geo panel",
589 div!(class = "muted geo-title", "Geolocation"),
590 div!(class = "geo-grid", cards),
591 )
592 });
593
594 html!(
595 lang = "en",
596 head!(
597 meta!(charset = "utf-8"),
598 meta!(
599 name = "viewport",
600 content = "width=device-width,initial-scale=1"
601 ),
602 link!(
603 rel = "icon",
604 href = PreEscaped(
605 "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'>\
606 <text y='0.9em' font-size='90'>π¦</text></svg>"
607 ),
608 ),
609 title!("Rama IP"),
610 link!(
611 rel = "stylesheet",
612 r#type = "text/css",
613 href = "/style/ip.css"
614 ),
615 ),
616 body!(
617 geo_comment,
618 div!(
619 class = "card",
620 div!(
621 class = "logo",
622 div!("π¦"),
623 div!(a!(href = "https://ramaproxy.org", "γ©γ")),
624 ),
625 div!(
626 class = "panel",
627 role = "region",
628 "aria-label" = "ip panel",
629 div!(class = "muted", "Your public ip"),
630 div!(id = "ip", class = "ip", code!(ip.to_string())),
631 div!(
632 class = "controls",
633 button!(
634 id = "copyBtn",
635 class = "primary",
636 title = "Copy ip to clipboard",
637 "π Copy IP",
638 ),
639 ),
640 ),
641 geo_panel,
642 script!(src = "/script/ip.js"),
643 )
644 ),
645 )
646}
647
648#[cfg(test)]
649mod render_html_page_tests {
650 use super::*;
651 use crate::http::protocols::html::IntoHtml as _;
652 use std::net::Ipv4Addr;
653
654 #[test]
659 fn render_html_page_embeds_ip_safely() {
660 let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
661 let out = render_html_page(ip, None, &[]).into_string();
662 assert!(out.starts_with("<!DOCTYPE html><html lang=\"en\">"));
663 assert!(out.contains("<title>Rama IP</title>"));
664 assert!(out.contains(r#"<div id="ip" class="ip"><code>127.0.0.1</code></div>"#));
665 assert!(out.contains(r#"id="copyBtn""#));
667 }
668
669 #[test]
672 fn render_html_page_emits_aria_label() {
673 let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 0, 1));
674 let out = render_html_page(ip, None, &[]).into_string();
675 assert!(out.contains(r#"aria-label="ip panel""#));
676 }
677
678 #[test]
684 fn render_html_page_uses_external_assets() {
685 let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
686 let out = render_html_page(ip, None, &[]).into_string();
687 assert!(
688 !out.contains("<style>") && !out.contains("<style "),
689 "IP page must not embed inline <style>; CSP blocks it"
690 );
691 assert!(
694 !out.contains("<script>"),
695 "IP page must not embed inline <script>; CSP blocks it"
696 );
697 assert!(
698 out.contains(r#"<link rel="stylesheet" type="text/css" href="/style/ip.css">"#),
699 "IP page must link to /style/ip.css",
700 );
701 assert!(
702 out.contains(r#"<script src="/script/ip.js">"#),
703 "IP page must source /script/ip.js",
704 );
705 }
706
707 #[test]
710 fn render_html_page_renders_geo_panel() {
711 use crate::geo::Country;
712 use crate::net::address::ip::geo::{GeoLocation, IpGeoInfo, IpGeoSourceResult};
713 let ip = IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4));
714 let loc = GeoLocation {
715 country: Some(Country::Belgium),
716 ..Default::default()
717 };
718 let info = IpGeoInfo {
719 ip,
720 location: loc.clone(),
721 by_source: vec![IpGeoSourceResult {
722 label: "geolite2".into(),
723 location: loc,
724 }],
725 };
726 let notices = ["This product includes GeoLite2 data created by MaxMind"];
727 let out = render_html_page(ip, Some(&info), ¬ices).into_string();
728 assert!(out.contains("Geolocation"), "geo panel title missing");
729 assert!(out.contains("Belgium"), "resolved country missing");
730 assert!(out.contains("geolite2"), "per-source label missing");
731 assert!(
733 out.contains("<!-- This product includes GeoLite2"),
734 "attribution comment missing"
735 );
736
737 let plain = render_html_page(ip, None, &[]).into_string();
739 assert!(!plain.contains("Geolocation"));
740 assert!(!plain.contains("<!--"));
741 }
742}