Skip to main content

rama/cli/service/
ip.rs

1//! IP '[`Service`] that echos the client IP either over http or directly over tcp.
2//!
3//! [`Service`]: crate::Service
4
5#![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 std::{convert::Infallible, marker::PhantomData, net::IpAddr, sync::Arc, time::Duration};
11
12use tokio::io::AsyncWriteExt;
13
14use crate::{
15    Layer, Service,
16    cli::{DEFAULT_CONNECTION_GRACE, ForwardKind, connection_scope},
17    combinators::{Either, Either3, Either7},
18    error::{BoxError, BoxErrorExt, ErrorExt as _},
19    extensions::ExtensionsRef,
20    graceful::WeakScope,
21    http::{
22        BodyLimitLayer, Request, Response, StatusCode, Version,
23        headers::{
24            Accept, AltSvc, HeaderMapExt,
25            exotic::XClacksOverhead,
26            forwarded::{CFConnectingIp, ClientIp, TrueClientIp, XClientIp, XRealIp},
27        },
28        layer::{
29            forwarded::GetForwardedHeaderLayer, required_header::AddRequiredResponseHeadersLayer,
30            set_header::SetResponseHeaderLayer, trace::TraceLayer,
31        },
32        mime,
33        server::HttpServer,
34        service::web::response::{Css, IntoResponse, Json, Redirect, Script},
35    },
36    io::Io,
37    layer::{
38        ConsumeErrLayer, LimitLayer, ScopeLayer,
39        limit::policy::{ConcurrentPolicy, RateLimitReached, RatePolicy, UnlimitedPolicy},
40    },
41    net::{
42        address::ip::geo::{GeoLocation, IpGeoDb, IpGeoInfo},
43        stream::layer::{ThrottleLayer, ThrottleMode},
44    },
45    proxy::haproxy::server::HaProxyLayer,
46    tcp::TcpStream,
47    telemetry::tracing,
48    utils::{octets::mib, rate::Rate},
49};
50
51#[cfg(feature = "quic")]
52use crate::quic;
53
54core::cfg_select! {
55    feature = "boring" => {
56        use crate::tls::boring::server::TlsAcceptorLayer;
57    }
58    feature = "rustls" => {
59        use crate::tls::rustls::server::TlsAcceptorLayer;
60    }
61    _ => {}
62}
63
64#[cfg(any(feature = "rustls", feature = "boring"))]
65use crate::{http::headers::StrictTransportSecurity, tls::server::TlsServerConfig};
66
67#[derive(Debug, Clone)]
68/// Builder that can be used to run your own ip [`Service`],
69/// echo'ing back the client IP over http or tcp.
70pub struct IpServiceBuilder<M> {
71    #[cfg(any(feature = "rustls", feature = "boring"))]
72    tls_server_config: Option<TlsServerConfig>,
73    concurrent_limit: usize,
74    rate_limit: Option<Rate>,
75    throttle: Option<Rate>,
76    timeout: Duration,
77    timeout_grace: Duration,
78    forward: Option<ForwardKind>,
79    geo_db: Option<Arc<IpGeoDb>>,
80    http_version: Option<Version>,
81    alt_svc: Option<AltSvc>,
82    _mode: PhantomData<fn(M)>,
83}
84
85impl IpServiceBuilder<mode::Http> {
86    /// Create a new [`IpServiceBuilder`], echoing the IP back over L4.
87    #[must_use]
88    pub fn http() -> Self {
89        Self {
90            #[cfg(any(feature = "rustls", feature = "boring"))]
91            tls_server_config: None,
92            concurrent_limit: 0,
93            rate_limit: None,
94            throttle: None,
95            timeout: Duration::ZERO,
96            timeout_grace: DEFAULT_CONNECTION_GRACE,
97            forward: None,
98            geo_db: None,
99            http_version: None,
100            alt_svc: None,
101            _mode: PhantomData,
102        }
103    }
104}
105
106impl IpServiceBuilder<mode::Transport> {
107    /// Create a new [`IpServiceBuilder`], echoing the IP back over L4.
108    #[must_use]
109    pub fn tcp() -> Self {
110        Self {
111            #[cfg(any(feature = "rustls", feature = "boring"))]
112            tls_server_config: None,
113            concurrent_limit: 0,
114            rate_limit: None,
115            throttle: None,
116            timeout: Duration::ZERO,
117            timeout_grace: DEFAULT_CONNECTION_GRACE,
118            forward: None,
119            geo_db: None,
120            http_version: None,
121            alt_svc: None,
122            _mode: PhantomData,
123        }
124    }
125}
126
127impl<M> IpServiceBuilder<M> {
128    crate::utils::macros::generate_set_and_with! {
129        /// set the number of concurrent connections to allow
130        #[must_use]
131        pub fn concurrent(mut self, limit: usize) -> Self {
132            self.concurrent_limit = limit;
133            self
134        }
135    }
136
137    crate::utils::macros::generate_set_and_with! {
138        /// rate limit the service, in requests per second for http mode
139        /// (rejected with a 429 + Retry-After response) or new connections
140        /// per second for tcp mode
141        #[must_use]
142        pub fn rate_limit(mut self, rate: Option<Rate>) -> Self {
143            self.rate_limit = rate;
144            self
145        }
146    }
147
148    crate::utils::macros::generate_set_and_with! {
149        /// throttle each connection at the given byte rate
150        /// (both directions, each with its own budget)
151        #[must_use]
152        pub fn throttle(mut self, rate: Option<Rate>) -> Self {
153            self.throttle = rate;
154            self
155        }
156    }
157
158    crate::utils::macros::generate_set_and_with! {
159        /// set the age at which each connection starts draining, give or take a tenth; it is
160        /// dropped [`with_timeout_grace`](Self::with_timeout_grace) later
161        ///
162        /// (0 = no limit)
163        #[must_use]
164        pub fn timeout(mut self, timeout: Duration) -> Self {
165            self.timeout = timeout;
166            self
167        }
168    }
169
170    crate::utils::macros::generate_set_and_with! {
171        /// set at most how long a draining connection may still run before it is dropped
172        ///
173        /// Also bounds the drain when the server shuts down; the server's own scope can end it
174        /// sooner. Default: 5 seconds.
175        pub fn timeout_grace(mut self, timeout_grace: Duration) -> Self {
176            self.timeout_grace = timeout_grace;
177            self
178        }
179    }
180
181    crate::utils::macros::generate_set_and_with! {
182        /// maybe enable support for one of the following "forward" headers or protocols
183        ///
184        /// Supported headers:
185        ///
186        /// Forwarded ("for="), X-Forwarded-For
187        ///
188        /// X-Client-IP Client-IP, X-Real-IP
189        ///
190        /// CF-Connecting-IP, True-Client-IP
191        ///
192        /// Or using HaProxy protocol.
193        #[must_use]
194        pub fn forward(mut self, maybe_kind: Option<ForwardKind>) -> Self {
195            self.forward = maybe_kind;
196            self
197        }
198    }
199
200    crate::utils::macros::generate_set_and_with! {
201        /// attach an IP geolocation database, enabling geo enrichment of the
202        /// HTTP (JSON) response. Typically built from `RAMA_IP_GEO_DB`.
203        #[must_use]
204        pub fn geo_db(mut self, db: Option<Arc<IpGeoDb>>) -> Self {
205            self.geo_db = db;
206            self
207        }
208    }
209
210    crate::utils::macros::generate_set_and_with! {
211        #[cfg(any(feature = "rustls", feature = "boring"))]
212        /// define a tls server cert config to be used for tls terminaton
213        /// by the IP service.
214        pub fn tls_server_config(mut self, cfg: Option<TlsServerConfig>) -> Self {
215            self.tls_server_config = cfg;
216            self
217        }
218    }
219}
220
221impl IpServiceBuilder<mode::Http> {
222    crate::utils::macros::generate_set_and_with! {
223        /// set the http version to serve over TCP (auto by default)
224        #[must_use]
225        pub fn http_version(mut self, version: Option<Version>) -> Self {
226            self.http_version = version;
227            self
228        }
229    }
230
231    crate::utils::macros::generate_set_and_with! {
232        /// advertise alternative services, such as HTTP/3, on every response
233        #[must_use]
234        pub fn alt_svc(mut self, alt_svc: Option<AltSvc>) -> Self {
235            self.alt_svc = alt_svc;
236            self
237        }
238    }
239
240    #[inline]
241    /// build a tcp service ready to echo the client IP back
242    pub fn build(
243        self,
244        scope: impl Into<WeakScope>,
245    ) -> Result<impl Service<TcpStream, Output = (), Error = Infallible>, BoxError> {
246        let scope = scope.into();
247        self.build_services(scope).map(|(tcp, _)| tcp)
248    }
249
250    /// build a tcp service and an HTTP/3 service, for accepted QUIC connections, ready to
251    /// echo the client IP back
252    ///
253    /// Both serve one http service: they share its rate limit, the connection limit,
254    /// the connection timeout and the per-connection throttle. TLS for QUIC is
255    /// configured on its endpoint.
256    #[cfg(feature = "quic")]
257    #[cfg_attr(docsrs, doc(cfg(feature = "quic")))]
258    pub fn build_with_http3(
259        self,
260        scope: impl Into<WeakScope>,
261    ) -> Result<
262        (
263            impl Service<TcpStream, Output = (), Error = Infallible>,
264            impl Service<quic::Connection, Output = (), Error = Infallible>,
265        ),
266        BoxError,
267    > {
268        let scope = scope.into();
269        self.build_services(scope)
270    }
271
272    fn build_services(
273        self,
274        scope: WeakScope,
275    ) -> Result<
276        (
277            impl Service<TcpStream, Output = (), Error = Infallible>,
278            impl super::MaybeHttp3Service,
279        ),
280        BoxError,
281    > {
282        #[cfg(any(feature = "rustls", feature = "boring"))]
283        let maybe_tls_accept_layer = self.tls_server_config.map(TlsAcceptorLayer::new);
284
285        let (tcp_forwarded_layer, http_forwarded_layer) = match &self.forward {
286            None => (None, None),
287            Some(ForwardKind::Forwarded) => {
288                (None, Some(Either7::A(GetForwardedHeaderLayer::forwarded())))
289            }
290            Some(ForwardKind::XForwardedFor) => (
291                None,
292                Some(Either7::B(GetForwardedHeaderLayer::x_forwarded_for())),
293            ),
294            Some(ForwardKind::XClientIp) => (
295                None,
296                Some(Either7::C(GetForwardedHeaderLayer::<XClientIp>::new())),
297            ),
298            Some(ForwardKind::ClientIp) => (
299                None,
300                Some(Either7::D(GetForwardedHeaderLayer::<ClientIp>::new())),
301            ),
302            Some(ForwardKind::XRealIp) => (
303                None,
304                Some(Either7::E(GetForwardedHeaderLayer::<XRealIp>::new())),
305            ),
306            Some(ForwardKind::CFConnectingIp) => (
307                None,
308                Some(Either7::F(GetForwardedHeaderLayer::<CFConnectingIp>::new())),
309            ),
310            Some(ForwardKind::TrueClientIp) => (
311                None,
312                Some(Either7::G(GetForwardedHeaderLayer::<TrueClientIp>::new())),
313            ),
314            Some(ForwardKind::HaProxy) => (Some(HaProxyLayer::default()), None),
315        };
316
317        #[cfg(any(feature = "rustls", feature = "boring"))]
318        let hsts_layer = maybe_tls_accept_layer.is_some().then(|| {
319            SetResponseHeaderLayer::if_not_present_typed(
320                StrictTransportSecurity::excluding_subdomains_for_max_seconds(31536000),
321            )
322        });
323
324        let connection_limit = (self.concurrent_limit > 0)
325            .then(|| LimitLayer::new(ConcurrentPolicy::max(self.concurrent_limit)));
326        let connection_timeout = connection_scope(&scope, self.timeout, self.timeout_grace);
327        // One layer for both: QUIC paces all streams of a connection against one budget.
328        let throttle = self
329            .throttle
330            .map(|rate| ThrottleLayer::symmetric(ThrottleMode::per_conn(rate)));
331        let tcp_service_builder = (
332            ConsumeErrLayer::trace_as(tracing::Level::DEBUG),
333            connection_limit.clone(),
334            connection_timeout.clone(),
335            throttle.clone(),
336            tcp_forwarded_layer,
337            // Limit the body size to 1MB for requests
338            BodyLimitLayer::request_only(mib(1)),
339            #[cfg(any(feature = "rustls", feature = "boring"))]
340            maybe_tls_accept_layer,
341        );
342
343        // Defence-in-depth response headers for the HTML page (txt/json
344        // responses also get them β€” they're benign there and means
345        // any future widening of HTML emission is already covered).
346        // The page loads `/style/ip.css` and `/script/ip.js` from the
347        // same origin, no inline scripts/styles, no external requests:
348        // the strict-self baseline (banner image whitelisted in the
349        // shared helper) covers it.
350        let (csp_layer, nosniff_layer, referrer_layer, frame_layer) =
351            crate::cli::service::http_security::defence_in_depth_layer(
352                crate::cli::service::http_security::rama_html_csp(),
353            );
354
355        // Attribution header, derived from the loaded databases' notices.
356        let geo_attribution = self.geo_db.as_ref().and_then(|db| {
357            let notices: Vec<_> = db.attributions().collect();
358            (!notices.is_empty()).then(|| crate::cli::service::geo::geo_attribution_layer(notices))
359        });
360
361        // Route the IP echo + its asset sidecars through a Router so we
362        // get clean method-aware matching (anything outside the three
363        // known routes redirects to `/`).
364        let router = crate::http::service::web::Router::new()
365            .with_get(
366                "/",
367                HttpIpService {
368                    geo_db: self.geo_db,
369                },
370            )
371            .with_get("/style/ip.css", Css(IP_STYLE_CSS))
372            .with_get("/script/ip.js", Script(IP_SCRIPT_JS))
373            .with_not_found(async || Redirect::permanent("/"));
374
375        let http_service = (
376            TraceLayer::new_for_http(),
377            SetResponseHeaderLayer::<XClacksOverhead>::if_not_present_default_typed(),
378            self.alt_svc
379                .map(SetResponseHeaderLayer::if_not_present_typed),
380            AddRequiredResponseHeadersLayer::default(),
381            self.rate_limit.map(|rate| {
382                LimitLayer::new(RatePolicy::abort(rate)).with_error_into_response_fn(
383                    |err: RateLimitReached| Ok::<_, Infallible>(err.into_response()),
384                )
385            }),
386            geo_attribution,
387            csp_layer,
388            nosniff_layer,
389            referrer_layer,
390            frame_layer,
391            ConsumeErrLayer::default(),
392            #[cfg(any(feature = "rustls", feature = "boring"))]
393            hsts_layer,
394            http_forwarded_layer,
395        )
396            .into_layer(router);
397
398        // Wrap in `Arc` because `Router` is not `Clone` and
399        // `HttpServer::service` requires a cloneable inner service so it
400        // can hand a copy to each connection's task.
401        let http_service = Arc::new(http_service);
402        let http_transport_service = match self.http_version {
403            None => Either3::A(HttpServer::auto(scope.clone()).service(http_service.clone())),
404            Some(Version::HTTP_2) => {
405                Either3::B(HttpServer::new_h2(scope.clone()).service(http_service.clone()))
406            }
407            Some(Version::HTTP_11 | Version::HTTP_10 | Version::HTTP_09) => {
408                Either3::C(HttpServer::new_http1(scope.clone()).service(http_service.clone()))
409            }
410            Some(version) => {
411                return Err(BoxError::from_static_str("unsupported http version")
412                    .context_debug_field("version", version));
413            }
414        };
415        #[cfg(feature = "quic")]
416        let http3_service = (
417            ConsumeErrLayer::trace_as(tracing::Level::DEBUG),
418            connection_limit,
419            connection_timeout,
420            throttle,
421            BodyLimitLayer::request_only(mib(1)),
422        )
423            .into_layer(HttpServer::new_http3(scope).service(http_service));
424        #[cfg(not(feature = "quic"))]
425        let http3_service = (
426            connection_limit,
427            connection_timeout,
428            throttle,
429            http_service,
430            scope,
431        );
432
433        Ok((
434            tcp_service_builder.into_layer(http_transport_service),
435            http3_service,
436        ))
437    }
438}
439
440#[derive(Debug, Clone)]
441/// The inner http ip-service used by the [`IpServiceBuilder`]. Mounted at
442/// `/` by the surrounding [`crate::http::service::web::Router`] in
443/// [`IpServiceBuilder::build_http`]; the asset sidecars are sibling
444/// routes on the same router.
445struct HttpIpService {
446    /// Optional geolocation database; when present, the JSON response is
447    /// enriched with the resolved location (merged + per-source).
448    geo_db: Option<Arc<IpGeoDb>>,
449}
450
451impl Service<Request> for HttpIpService {
452    type Output = Response;
453    type Error = Infallible;
454
455    async fn serve(&self, req: Request) -> Result<Self::Output, Self::Error> {
456        let peer_ip = crate::net::client_ip::client_ip(&req);
457
458        Ok(match peer_ip {
459            Some(ip) => match HttpBodyContentFormat::derive_from_req(&req) {
460                HttpBodyContentFormat::Txt => ip.to_string().into_response(),
461                HttpBodyContentFormat::Html => {
462                    let geo = self.geo_db.as_ref().and_then(|db| db.resolve(ip));
463                    let attributions: Vec<_> = self
464                        .geo_db
465                        .as_ref()
466                        .map(|db| db.attributions().collect())
467                        .unwrap_or_default();
468                    render_html_page(ip, geo.as_ref(), &attributions).into_response()
469                }
470                HttpBodyContentFormat::Json => {
471                    let geo = self.geo_db.as_ref().and_then(|db| db.resolve(ip));
472                    let mut body = serde_json::json!({ "ip": ip });
473                    if let Some(info) = geo {
474                        // attribution rides in the x-geo-attribution header, not the body
475                        body["geo"] = serde_json::to_value(&info).unwrap_or_default();
476                    }
477                    Json(body).into_response()
478                }
479            },
480            None => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
481        })
482    }
483}
484
485/// Sidecar stylesheet for the HTML page. Served as a separate route so
486/// the defence-in-depth CSP can keep `style-src 'self'` (blocking
487/// inline `<style>`) without breaking the page.
488const IP_STYLE_CSS: &str = include_str!("ip.css");
489
490/// Sidecar clipboard-copy script. Served separately for the same
491/// reason as [`IP_STYLE_CSS`] (`script-src 'self'`).
492const IP_SCRIPT_JS: &str = include_str!("ip.js");
493
494#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
495enum HttpBodyContentFormat {
496    #[default]
497    Txt,
498    Html,
499    Json,
500}
501
502impl HttpBodyContentFormat {
503    fn derive_from_req(req: &Request) -> Self {
504        let Some(accept) = req.headers().typed_get::<Accept>() else {
505            return Self::default();
506        };
507        // honour q-values: try the most-preferred media types first (stable
508        // sort, so equal-quality entries keep their header order)
509        let mut entries: Vec<_> = accept.0.iter().collect();
510        entries.sort_by_key(|qv| std::cmp::Reverse(qv.quality));
511        entries
512            .into_iter()
513            .find_map(|qv| {
514                let r#type = qv.value.subtype();
515                if r#type == mime::JSON {
516                    Some(Self::Json)
517                } else if r#type == mime::HTML {
518                    Some(Self::Html)
519                } else if r#type == mime::TEXT {
520                    Some(Self::Txt)
521                } else {
522                    None
523                }
524            })
525            .unwrap_or_default()
526    }
527}
528
529#[derive(Debug, Clone)]
530#[non_exhaustive]
531/// The inner tcp echo-service used by the [`IpServiceBuilder`].
532struct TcpIpService;
533
534impl<Input> Service<Input> for TcpIpService
535where
536    Input: Io + Unpin + ExtensionsRef,
537{
538    type Output = ();
539    type Error = BoxError;
540
541    async fn serve(&self, stream: Input) -> Result<Self::Output, Self::Error> {
542        tracing::info!("connection received");
543        let peer_ip = crate::net::client_ip::client_ip(&stream);
544        let Some(peer_ip) = peer_ip else {
545            tracing::error!("missing peer information");
546            return Ok(());
547        };
548
549        let mut stream = std::pin::pin!(stream);
550
551        match peer_ip {
552            std::net::IpAddr::V4(ip) => {
553                if let Err(err) = stream.write_all(&ip.octets()).await {
554                    tracing::error!("error writing IPv4 of peer to peer: {}", err);
555                }
556            }
557            std::net::IpAddr::V6(ip) => {
558                if let Err(err) = stream.write_all(&ip.octets()).await {
559                    tracing::error!("error writing IPv6 of peer to peer: {}", err);
560                }
561            }
562        };
563
564        Ok(())
565    }
566}
567
568impl IpServiceBuilder<mode::Transport> {
569    #[allow(unused_mut)]
570    #[inline]
571    /// build a tcp service ready to echo client IP back
572    pub fn build(
573        mut self,
574        scope: impl Into<WeakScope>,
575    ) -> Result<impl Service<TcpStream, Output = (), Error = Infallible>, BoxError> {
576        let connection_scope = connection_scope(&scope.into(), self.timeout, self.timeout_grace);
577        #[cfg(any(feature = "rustls", feature = "boring"))]
578        {
579            let maybe_tls_acceptor_layer = self.tls_server_config.take().map(TlsAcceptorLayer::new);
580            self.build_tcp(connection_scope, maybe_tls_acceptor_layer)
581        }
582
583        #[cfg(not(any(feature = "rustls", feature = "boring")))]
584        self.build_tcp(connection_scope)
585    }
586}
587
588impl<M> IpServiceBuilder<M> {
589    fn build_tcp<S: Io + ExtensionsRef + Unpin + Sync>(
590        self,
591        connection_scope: ScopeLayer,
592        #[cfg(any(feature = "rustls", feature = "boring"))] maybe_tls_accept_layer: Option<
593            TlsAcceptorLayer,
594        >,
595    ) -> Result<impl Service<S, Output = (), Error = Infallible>, BoxError> {
596        let tcp_forwarded_layer = match &self.forward {
597            None => None,
598            Some(ForwardKind::HaProxy) => Some(HaProxyLayer::default()),
599            Some(other) => {
600                return Err(
601                    BoxError::from_static_str("invalid forward kind for Transport mode")
602                        .with_context_debug_field("kind", || other.clone()),
603                );
604            }
605        };
606
607        let tcp_service_builder = (
608            ConsumeErrLayer::trace_as(tracing::Level::DEBUG),
609            self.rate_limit
610                .map(|rate| LimitLayer::new(RatePolicy::abort(rate))),
611            LimitLayer::new(if self.concurrent_limit > 0 {
612                Either::A(ConcurrentPolicy::max(self.concurrent_limit))
613            } else {
614                Either::B(UnlimitedPolicy::new())
615            }),
616            connection_scope,
617            self.throttle
618                .map(|rate| ThrottleLayer::symmetric(ThrottleMode::per_conn(rate))),
619            tcp_forwarded_layer,
620            #[cfg(any(feature = "rustls", feature = "boring"))]
621            maybe_tls_accept_layer,
622        );
623
624        Ok(tcp_service_builder.into_layer(TcpIpService))
625    }
626}
627
628pub mod mode {
629    //! operation modes of the ip service
630
631    #[derive(Debug, Clone)]
632    #[non_exhaustive]
633    /// Default mode of the Ip service, echo'ng the info back over http
634    pub struct Http;
635
636    #[derive(Debug, Clone)]
637    #[non_exhaustive]
638    /// Alternative mode of the Ip service, echo'ng the ip info over tcp
639    pub struct Transport;
640}
641
642fn render_html_page(
643    ip: IpAddr,
644    geo: Option<&IpGeoInfo>,
645    attributions: &[&str],
646) -> impl crate::http::protocols::html::IntoHtml + IntoResponse {
647    use crate::http::protocols::html::*;
648
649    // attribution comment from the loaded databases; geo panel when resolved
650    let geo_comment =
651        crate::cli::service::geo::geo_attribution_html_comment(attributions).map(PreEscaped);
652    let geo_panel = geo.map(|info| {
653        let rows = |loc: &GeoLocation| {
654            crate::cli::service::geo::geo_location_rows(loc)
655                .into_iter()
656                .map(|(k, v)| div!(class = "georow", div!(class = "muted", k), div!(code!(v))))
657                .collect::<Vec<_>>()
658        };
659        // merged result + one card per source, laid out in a responsive grid
660        let card = |label: String, loc: &GeoLocation| {
661            div!(
662                class = "panel geo-card",
663                div!(class = "muted geo-source", label),
664                rows(loc),
665            )
666        };
667        let mut cards = vec![card("merged".to_owned(), &info.location)];
668        cards.extend(
669            info.by_source
670                .iter()
671                .map(|src| card(src.label.to_string(), &src.location)),
672        );
673        div!(
674            class = "geo-section",
675            role = "region",
676            "aria-label" = "geo panel",
677            div!(class = "muted geo-title", "Geolocation"),
678            div!(class = "geo-grid", cards),
679        )
680    });
681
682    html!(
683        lang = "en",
684        head!(
685            meta!(charset = "utf-8"),
686            meta!(
687                name = "viewport",
688                content = "width=device-width,initial-scale=1"
689            ),
690            link!(
691                rel = "icon",
692                href = PreEscaped(
693                    "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'>\
694                     <text y='0.9em' font-size='90'>πŸ¦™</text></svg>"
695                ),
696            ),
697            title!("Rama IP"),
698            link!(
699                rel = "stylesheet",
700                r#type = "text/css",
701                href = "/style/ip.css"
702            ),
703        ),
704        body!(
705            geo_comment,
706            div!(
707                class = "card",
708                div!(
709                    class = "logo",
710                    div!("πŸ¦™"),
711                    div!(a!(href = "https://ramaproxy.org", "γƒ©γƒž")),
712                ),
713                div!(
714                    class = "panel",
715                    role = "region",
716                    "aria-label" = "ip panel",
717                    div!(class = "muted", "Your public ip"),
718                    div!(id = "ip", class = "ip", code!(ip.to_string())),
719                    div!(
720                        class = "controls",
721                        button!(
722                            id = "copyBtn",
723                            class = "primary",
724                            title = "Copy ip to clipboard",
725                            "πŸ“‹ Copy IP",
726                        ),
727                    ),
728                ),
729                geo_panel,
730                script!(src = "/script/ip.js"),
731            )
732        ),
733    )
734}
735
736#[cfg(test)]
737mod render_html_page_tests {
738    use std::net::Ipv4Addr;
739
740    use super::*;
741    use crate::http::protocols::html::IntoHtml as _;
742
743    /// The IP value flows through `html!`'s escape pipeline, so even if a
744    /// future `IpAddr::Display` impl produced HTML-special chars they would
745    /// be neutralised. Verify the rendered page contains the expected IP
746    /// inside `<code>…</code>` and that the page chrome is well-formed.
747    #[test]
748    fn render_html_page_embeds_ip_safely() {
749        let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
750        let out = render_html_page(ip, None, &[]).into_string();
751        assert!(out.starts_with("<!DOCTYPE html><html lang=\"en\">"));
752        assert!(out.contains("<title>Rama IP</title>"));
753        assert!(out.contains(r#"<div id="ip" class="ip"><code>127.0.0.1</code></div>"#));
754        // Copy button is wired by selector ID in the inline script.
755        assert!(out.contains(r#"id="copyBtn""#));
756    }
757
758    /// The aria-label attribute uses the `"aria-label" = …` syntax (since
759    /// `aria-label` is not a Rust ident). Pin the rendered output.
760    #[test]
761    fn render_html_page_emits_aria_label() {
762        let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 0, 1));
763        let out = render_html_page(ip, None, &[]).into_string();
764        assert!(out.contains(r#"aria-label="ip panel""#));
765    }
766
767    /// Regression guard against the bug audited 2026-05-18: the IP page
768    /// must reference its CSS and JS via `<link>` / `<script src>`
769    /// because the surrounding service applies `style-src 'self'` and
770    /// `script-src 'self'` β€” an inline `<style>` or `<script>` block
771    /// would be blocked at the browser.
772    #[test]
773    fn render_html_page_uses_external_assets() {
774        let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
775        let out = render_html_page(ip, None, &[]).into_string();
776        assert!(
777            !out.contains("<style>") && !out.contains("<style "),
778            "IP page must not embed inline <style>; CSP blocks it"
779        );
780        // The renderer is allowed to emit a self-closing `<script src=...>`,
781        // but never an inline `<script>...JS...</script>` body.
782        assert!(
783            !out.contains("<script>"),
784            "IP page must not embed inline <script>; CSP blocks it"
785        );
786        assert!(
787            out.contains(r#"<link rel="stylesheet" type="text/css" href="/style/ip.css">"#),
788            "IP page must link to /style/ip.css",
789        );
790        assert!(
791            out.contains(r#"<script src="/script/ip.js">"#),
792            "IP page must source /script/ip.js",
793        );
794    }
795
796    /// When a location is resolved, the page renders a geo panel (merged +
797    /// per-source) and embeds the attribution as an HTML comment.
798    #[test]
799    fn render_html_page_renders_geo_panel() {
800        use crate::{
801            geo::Country,
802            net::address::ip::geo::{GeoLocation, IpGeoInfo, IpGeoSourceResult},
803        };
804        let ip = IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4));
805        let loc = GeoLocation {
806            country: Some(Country::Belgium),
807            ..Default::default()
808        };
809        let info = IpGeoInfo {
810            ip,
811            location: loc.clone(),
812            by_source: vec![IpGeoSourceResult {
813                label: "geolite2".into(),
814                location: loc,
815            }],
816        };
817        let notices = ["This product includes GeoLite2 data created by MaxMind"];
818        let out = render_html_page(ip, Some(&info), &notices).into_string();
819        assert!(out.contains("Geolocation"), "geo panel title missing");
820        assert!(out.contains("Belgium"), "resolved country missing");
821        assert!(out.contains("geolite2"), "per-source label missing");
822        // attribution is an HTML comment, never visible structured data
823        assert!(
824            out.contains("<!-- This product includes GeoLite2"),
825            "attribution comment missing"
826        );
827
828        // …and absent when no database is configured
829        let plain = render_html_page(ip, None, &[]).into_string();
830        assert!(!plain.contains("Geolocation"));
831        assert!(!plain.contains("<!--"));
832    }
833}