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::{ConsumeErrLayer, LimitLayer, TimeoutLayer, limit::policy::ConcurrentPolicy},
34 net::address::ip::geo::{GeoLocation, IpGeoDb, IpGeoInfo},
35 net::forwarded::Forwarded,
36 net::stream::SocketInfo,
37 proxy::haproxy::server::HaProxyLayer,
38 rt::Executor,
39 tcp::TcpStream,
40 telemetry::tracing,
41 utils::octets::mib,
42};
43
44use std::{convert::Infallible, marker::PhantomData, net::IpAddr, sync::Arc, time::Duration};
45use tokio::io::AsyncWriteExt;
46
47core::cfg_select! {
48 feature = "boring" => {
49 use crate::tls::boring::server::TlsAcceptorLayer;
50 }
51 feature = "rustls" => {
52 use crate::tls::rustls::server::TlsAcceptorLayer;
53 }
54 _ => {}
55}
56
57#[cfg(any(feature = "rustls", feature = "boring"))]
58use crate::{http::headers::StrictTransportSecurity, tls::server::TlsServerConfig};
59
60#[derive(Debug, Clone)]
61pub struct IpServiceBuilder<M> {
64 #[cfg(any(feature = "rustls", feature = "boring"))]
65 tls_server_config: Option<TlsServerConfig>,
66 concurrent_limit: usize,
67 timeout: Duration,
68 forward: Option<ForwardKind>,
69 geo_db: Option<Arc<IpGeoDb>>,
70 _mode: PhantomData<fn(M)>,
71}
72
73impl IpServiceBuilder<mode::Http> {
74 #[must_use]
76 pub fn http() -> Self {
77 Self {
78 #[cfg(any(feature = "rustls", feature = "boring"))]
79 tls_server_config: None,
80 concurrent_limit: 0,
81 timeout: Duration::ZERO,
82 forward: None,
83 geo_db: None,
84 _mode: PhantomData,
85 }
86 }
87}
88
89impl IpServiceBuilder<mode::Transport> {
90 #[must_use]
92 pub fn tcp() -> Self {
93 Self {
94 #[cfg(any(feature = "rustls", feature = "boring"))]
95 tls_server_config: None,
96 concurrent_limit: 0,
97 timeout: Duration::ZERO,
98 forward: None,
99 geo_db: None,
100 _mode: PhantomData,
101 }
102 }
103}
104
105impl<M> IpServiceBuilder<M> {
106 crate::utils::macros::generate_set_and_with! {
107 #[must_use]
109 pub fn concurrent(mut self, limit: usize) -> Self {
110 self.concurrent_limit = limit;
111 self
112 }
113 }
114
115 crate::utils::macros::generate_set_and_with! {
116 #[must_use]
118 pub fn timeout(mut self, timeout: Duration) -> Self {
119 self.timeout = timeout;
120 self
121 }
122 }
123
124 crate::utils::macros::generate_set_and_with! {
125 #[must_use]
137 pub fn forward(mut self, maybe_kind: Option<ForwardKind>) -> Self {
138 self.forward = maybe_kind;
139 self
140 }
141 }
142
143 crate::utils::macros::generate_set_and_with! {
144 #[must_use]
147 pub fn geo_db(mut self, db: Option<Arc<IpGeoDb>>) -> Self {
148 self.geo_db = db;
149 self
150 }
151 }
152
153 crate::utils::macros::generate_set_and_with! {
154 #[cfg(any(feature = "rustls", feature = "boring"))]
155 pub fn tls_server_config(mut self, cfg: Option<TlsServerConfig>) -> Self {
158 self.tls_server_config = cfg;
159 self
160 }
161 }
162}
163
164impl IpServiceBuilder<mode::Http> {
165 #[allow(unused_mut)]
166 #[inline]
167 pub fn build(
169 mut self,
170 executor: Executor,
171 ) -> Result<impl Service<TcpStream, Output = (), Error = Infallible>, BoxError> {
172 #[cfg(any(feature = "rustls", feature = "boring"))]
173 {
174 let maybe_tls_acceptor_layer = self.tls_server_config.take().map(TlsAcceptorLayer::new);
175 self.build_http(executor, maybe_tls_acceptor_layer)
176 }
177
178 #[cfg(not(any(feature = "rustls", feature = "boring")))]
179 self.build_http(executor)
180 }
181}
182
183#[derive(Debug, Clone)]
184struct HttpIpService {
189 geo_db: Option<Arc<IpGeoDb>>,
192}
193
194impl Service<Request> for HttpIpService {
195 type Output = Response;
196 type Error = Infallible;
197
198 async fn serve(&self, req: Request) -> Result<Self::Output, Self::Error> {
199 let peer_ip = req
200 .extensions()
201 .get_ref::<Forwarded>()
202 .and_then(|f| f.client_ip())
203 .or_else(|| {
204 req.extensions()
205 .get_ref::<SocketInfo>()
206 .map(|s| s.peer_addr().ip_addr)
207 });
208
209 Ok(match peer_ip {
210 Some(ip) => match HttpBodyContentFormat::derive_from_req(&req) {
211 HttpBodyContentFormat::Txt => ip.to_string().into_response(),
212 HttpBodyContentFormat::Html => {
213 let geo = self.geo_db.as_ref().and_then(|db| db.resolve(ip));
214 let attributions: Vec<_> = self
215 .geo_db
216 .as_ref()
217 .map(|db| db.attributions().collect())
218 .unwrap_or_default();
219 render_html_page(ip, geo.as_ref(), &attributions).into_response()
220 }
221 HttpBodyContentFormat::Json => {
222 let geo = self.geo_db.as_ref().and_then(|db| db.resolve(ip));
223 let mut body = serde_json::json!({ "ip": ip });
224 if let Some(info) = geo {
225 body["geo"] = serde_json::to_value(&info).unwrap_or_default();
227 }
228 Json(body).into_response()
229 }
230 },
231 None => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
232 })
233 }
234}
235
236const IP_STYLE_CSS: &str = include_str!("ip.css");
240
241const IP_SCRIPT_JS: &str = include_str!("ip.js");
244
245#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
246enum HttpBodyContentFormat {
247 #[default]
248 Txt,
249 Html,
250 Json,
251}
252
253impl HttpBodyContentFormat {
254 fn derive_from_req(req: &Request) -> Self {
255 let Some(accept) = req.headers().typed_get::<Accept>() else {
256 return Self::default();
257 };
258 let mut entries: Vec<_> = accept.0.iter().collect();
261 entries.sort_by_key(|qv| std::cmp::Reverse(qv.quality));
262 entries
263 .into_iter()
264 .find_map(|qv| {
265 let r#type = qv.value.subtype();
266 if r#type == mime::JSON {
267 Some(Self::Json)
268 } else if r#type == mime::HTML {
269 Some(Self::Html)
270 } else if r#type == mime::TEXT {
271 Some(Self::Txt)
272 } else {
273 None
274 }
275 })
276 .unwrap_or_default()
277 }
278}
279
280#[derive(Debug, Clone)]
281#[non_exhaustive]
282struct TcpIpService;
284
285impl<Input> Service<Input> for TcpIpService
286where
287 Input: Io + Unpin + ExtensionsRef,
288{
289 type Output = ();
290 type Error = BoxError;
291
292 async fn serve(&self, stream: Input) -> Result<Self::Output, Self::Error> {
293 tracing::info!("connection received");
294 let peer_ip = stream
295 .extensions()
296 .get_ref::<Forwarded>()
297 .and_then(|f| f.client_ip())
298 .or_else(|| {
299 stream
300 .extensions()
301 .get_ref::<SocketInfo>()
302 .map(|s| s.peer_addr().ip_addr)
303 });
304 let Some(peer_ip) = peer_ip else {
305 tracing::error!("missing peer information");
306 return Ok(());
307 };
308
309 let mut stream = std::pin::pin!(stream);
310
311 match peer_ip {
312 std::net::IpAddr::V4(ip) => {
313 if let Err(err) = stream.write_all(&ip.octets()).await {
314 tracing::error!("error writing IPv4 of peer to peer: {}", err);
315 }
316 }
317 std::net::IpAddr::V6(ip) => {
318 if let Err(err) = stream.write_all(&ip.octets()).await {
319 tracing::error!("error writing IPv6 of peer to peer: {}", err);
320 }
321 }
322 };
323
324 Ok(())
325 }
326}
327
328impl IpServiceBuilder<mode::Transport> {
329 #[allow(unused_mut)]
330 #[inline]
331 pub fn build(
333 mut self,
334 ) -> Result<impl Service<TcpStream, Output = (), Error = Infallible>, BoxError> {
335 #[cfg(any(feature = "rustls", feature = "boring"))]
336 {
337 let maybe_tls_acceptor_layer = self.tls_server_config.take().map(TlsAcceptorLayer::new);
338 self.build_tcp(maybe_tls_acceptor_layer)
339 }
340
341 #[cfg(not(any(feature = "rustls", feature = "boring")))]
342 self.build_tcp()
343 }
344}
345
346impl<M> IpServiceBuilder<M> {
347 fn build_tcp<S: Io + ExtensionsRef + Unpin + Sync>(
348 self,
349 #[cfg(any(feature = "rustls", feature = "boring"))] maybe_tls_accept_layer: Option<
350 TlsAcceptorLayer,
351 >,
352 ) -> Result<impl Service<S, Output = (), Error = Infallible>, BoxError> {
353 let tcp_forwarded_layer = match &self.forward {
354 None => None,
355 Some(ForwardKind::HaProxy) => Some(HaProxyLayer::default()),
356 Some(other) => {
357 return Err(
358 BoxError::from_static_str("invalid forward kind for Transport mode")
359 .with_context_debug_field("kind", || other.clone()),
360 );
361 }
362 };
363
364 let tcp_service_builder = (
365 ConsumeErrLayer::trace_as(tracing::Level::DEBUG),
366 LimitLayer::new(if self.concurrent_limit > 0 {
367 Either::A(ConcurrentPolicy::max(self.concurrent_limit))
368 } else {
369 Either::B(UnlimitedPolicy::new())
370 }),
371 if !self.timeout.is_zero() {
372 TimeoutLayer::new(self.timeout)
373 } else {
374 TimeoutLayer::never()
375 },
376 tcp_forwarded_layer,
377 #[cfg(any(feature = "rustls", feature = "boring"))]
378 maybe_tls_accept_layer,
379 );
380
381 Ok(tcp_service_builder.into_layer(TcpIpService))
382 }
383
384 fn build_http<S: Io + Unpin + Sync + ExtensionsRef>(
385 self,
386 executor: Executor,
387 #[cfg(any(feature = "rustls", feature = "boring"))] maybe_tls_accept_layer: Option<
388 TlsAcceptorLayer,
389 >,
390 ) -> Result<impl Service<S, Output = (), Error = Infallible>, BoxError> {
391 let (tcp_forwarded_layer, http_forwarded_layer) = match &self.forward {
392 None => (None, None),
393 Some(ForwardKind::Forwarded) => {
394 (None, Some(Either7::A(GetForwardedHeaderLayer::forwarded())))
395 }
396 Some(ForwardKind::XForwardedFor) => (
397 None,
398 Some(Either7::B(GetForwardedHeaderLayer::x_forwarded_for())),
399 ),
400 Some(ForwardKind::XClientIp) => (
401 None,
402 Some(Either7::C(GetForwardedHeaderLayer::<XClientIp>::new())),
403 ),
404 Some(ForwardKind::ClientIp) => (
405 None,
406 Some(Either7::D(GetForwardedHeaderLayer::<ClientIp>::new())),
407 ),
408 Some(ForwardKind::XRealIp) => (
409 None,
410 Some(Either7::E(GetForwardedHeaderLayer::<XRealIp>::new())),
411 ),
412 Some(ForwardKind::CFConnectingIp) => (
413 None,
414 Some(Either7::F(GetForwardedHeaderLayer::<CFConnectingIp>::new())),
415 ),
416 Some(ForwardKind::TrueClientIp) => (
417 None,
418 Some(Either7::G(GetForwardedHeaderLayer::<TrueClientIp>::new())),
419 ),
420 Some(ForwardKind::HaProxy) => (Some(HaProxyLayer::default()), None),
421 };
422
423 #[cfg(any(feature = "rustls", feature = "boring"))]
424 let hsts_layer = maybe_tls_accept_layer.is_some().then(|| {
425 SetResponseHeaderLayer::if_not_present_typed(
426 StrictTransportSecurity::excluding_subdomains_for_max_seconds(31536000),
427 )
428 });
429
430 let tcp_service_builder = (
431 ConsumeErrLayer::trace_as(tracing::Level::DEBUG),
432 (self.concurrent_limit > 0)
433 .then(|| LimitLayer::new(ConcurrentPolicy::max(self.concurrent_limit))),
434 (!self.timeout.is_zero()).then(|| TimeoutLayer::new(self.timeout)),
435 tcp_forwarded_layer,
436 BodyLimitLayer::request_only(mib(1)),
438 #[cfg(any(feature = "rustls", feature = "boring"))]
439 maybe_tls_accept_layer,
440 );
441
442 let (csp_layer, nosniff_layer, referrer_layer, frame_layer) =
450 crate::cli::service::http_security::defence_in_depth_layer(
451 crate::cli::service::http_security::rama_html_csp(),
452 );
453
454 let geo_attribution = self.geo_db.as_ref().and_then(|db| {
456 let notices: Vec<_> = db.attributions().collect();
457 (!notices.is_empty()).then(|| crate::cli::service::geo::geo_attribution_layer(notices))
458 });
459
460 let router = crate::http::service::web::Router::new()
464 .with_get(
465 "/",
466 HttpIpService {
467 geo_db: self.geo_db,
468 },
469 )
470 .with_get("/style/ip.css", Css(IP_STYLE_CSS))
471 .with_get("/script/ip.js", Script(IP_SCRIPT_JS))
472 .with_not_found(async || Redirect::permanent("/"));
473
474 let http_service = (
475 TraceLayer::new_for_http(),
476 SetResponseHeaderLayer::<XClacksOverhead>::if_not_present_default_typed(),
477 AddRequiredResponseHeadersLayer::default(),
478 geo_attribution,
479 csp_layer,
480 nosniff_layer,
481 referrer_layer,
482 frame_layer,
483 ConsumeErrLayer::default(),
484 #[cfg(any(feature = "rustls", feature = "boring"))]
485 hsts_layer,
486 http_forwarded_layer,
487 )
488 .into_layer(router);
489
490 let http_service = Arc::new(http_service);
494 Ok(tcp_service_builder.into_layer(HttpServer::auto(executor).service(http_service)))
495 }
496}
497
498pub mod mode {
499 #[derive(Debug, Clone)]
502 #[non_exhaustive]
503 pub struct Http;
505
506 #[derive(Debug, Clone)]
507 #[non_exhaustive]
508 pub struct Transport;
510}
511
512fn render_html_page(
513 ip: IpAddr,
514 geo: Option<&IpGeoInfo>,
515 attributions: &[&str],
516) -> impl crate::http::protocols::html::IntoHtml + IntoResponse {
517 use crate::http::protocols::html::*;
518
519 let geo_comment =
521 crate::cli::service::geo::geo_attribution_html_comment(attributions).map(PreEscaped);
522 let geo_panel = geo.map(|info| {
523 let rows = |loc: &GeoLocation| {
524 crate::cli::service::geo::geo_location_rows(loc)
525 .into_iter()
526 .map(|(k, v)| div!(class = "georow", div!(class = "muted", k), div!(code!(v))))
527 .collect::<Vec<_>>()
528 };
529 let card = |label: String, loc: &GeoLocation| {
531 div!(
532 class = "panel geo-card",
533 div!(class = "muted geo-source", label),
534 rows(loc),
535 )
536 };
537 let mut cards = vec![card("merged".to_owned(), &info.location)];
538 cards.extend(
539 info.by_source
540 .iter()
541 .map(|src| card(src.label.to_string(), &src.location)),
542 );
543 div!(
544 class = "geo-section",
545 role = "region",
546 "aria-label" = "geo panel",
547 div!(class = "muted geo-title", "Geolocation"),
548 div!(class = "geo-grid", cards),
549 )
550 });
551
552 html!(
553 lang = "en",
554 head!(
555 meta!(charset = "utf-8"),
556 meta!(
557 name = "viewport",
558 content = "width=device-width,initial-scale=1"
559 ),
560 link!(
561 rel = "icon",
562 href = PreEscaped(
563 "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'>\
564 <text y='0.9em' font-size='90'>π¦</text></svg>"
565 ),
566 ),
567 title!("Rama IP"),
568 link!(
569 rel = "stylesheet",
570 r#type = "text/css",
571 href = "/style/ip.css"
572 ),
573 ),
574 body!(
575 geo_comment,
576 div!(
577 class = "card",
578 div!(
579 class = "logo",
580 div!("π¦"),
581 div!(a!(href = "https://ramaproxy.org", "γ©γ")),
582 ),
583 div!(
584 class = "panel",
585 role = "region",
586 "aria-label" = "ip panel",
587 div!(class = "muted", "Your public ip"),
588 div!(id = "ip", class = "ip", code!(ip.to_string())),
589 div!(
590 class = "controls",
591 button!(
592 id = "copyBtn",
593 class = "primary",
594 title = "Copy ip to clipboard",
595 "π Copy IP",
596 ),
597 ),
598 ),
599 geo_panel,
600 script!(src = "/script/ip.js"),
601 )
602 ),
603 )
604}
605
606#[cfg(test)]
607mod render_html_page_tests {
608 use super::*;
609 use crate::http::protocols::html::IntoHtml as _;
610 use std::net::Ipv4Addr;
611
612 #[test]
617 fn render_html_page_embeds_ip_safely() {
618 let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
619 let out = render_html_page(ip, None, &[]).into_string();
620 assert!(out.starts_with("<!DOCTYPE html><html lang=\"en\">"));
621 assert!(out.contains("<title>Rama IP</title>"));
622 assert!(out.contains(r#"<div id="ip" class="ip"><code>127.0.0.1</code></div>"#));
623 assert!(out.contains(r#"id="copyBtn""#));
625 }
626
627 #[test]
630 fn render_html_page_emits_aria_label() {
631 let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 0, 1));
632 let out = render_html_page(ip, None, &[]).into_string();
633 assert!(out.contains(r#"aria-label="ip panel""#));
634 }
635
636 #[test]
642 fn render_html_page_uses_external_assets() {
643 let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
644 let out = render_html_page(ip, None, &[]).into_string();
645 assert!(
646 !out.contains("<style>") && !out.contains("<style "),
647 "IP page must not embed inline <style>; CSP blocks it"
648 );
649 assert!(
652 !out.contains("<script>"),
653 "IP page must not embed inline <script>; CSP blocks it"
654 );
655 assert!(
656 out.contains(r#"<link rel="stylesheet" type="text/css" href="/style/ip.css">"#),
657 "IP page must link to /style/ip.css",
658 );
659 assert!(
660 out.contains(r#"<script src="/script/ip.js">"#),
661 "IP page must source /script/ip.js",
662 );
663 }
664
665 #[test]
668 fn render_html_page_renders_geo_panel() {
669 use crate::geo::Country;
670 use crate::net::address::ip::geo::{GeoLocation, IpGeoInfo, IpGeoSourceResult};
671 let ip = IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4));
672 let loc = GeoLocation {
673 country: Some(Country::Belgium),
674 ..Default::default()
675 };
676 let info = IpGeoInfo {
677 ip,
678 location: loc.clone(),
679 by_source: vec![IpGeoSourceResult {
680 label: "geolite2".into(),
681 location: loc,
682 }],
683 };
684 let notices = ["This product includes GeoLite2 data created by MaxMind"];
685 let out = render_html_page(ip, Some(&info), ¬ices).into_string();
686 assert!(out.contains("Geolocation"), "geo panel title missing");
687 assert!(out.contains("Belgium"), "resolved country missing");
688 assert!(out.contains("geolite2"), "per-source label missing");
689 assert!(
691 out.contains("<!-- This product includes GeoLite2"),
692 "attribution comment missing"
693 );
694
695 let plain = render_html_page(ip, None, &[]).into_string();
697 assert!(!plain.contains("Geolocation"));
698 assert!(!plain.contains("<!--"));
699 }
700}