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 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)]
61/// Builder that can be used to run your own ip [`Service`],
62/// echo'ing back the client IP over http or tcp.
63pub 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    /// Create a new [`IpServiceBuilder`], echoing the IP back over L4.
75    #[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    /// Create a new [`IpServiceBuilder`], echoing the IP back over L4.
91    #[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        /// set the number of concurrent connections to allow
108        #[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        /// set the timeout in seconds for each connection
117        #[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        /// maybe enable support for one of the following "forward" headers or protocols
126        ///
127        /// Supported headers:
128        ///
129        /// Forwarded ("for="), X-Forwarded-For
130        ///
131        /// X-Client-IP Client-IP, X-Real-IP
132        ///
133        /// CF-Connecting-IP, True-Client-IP
134        ///
135        /// Or using HaProxy protocol.
136        #[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        /// attach an IP geolocation database, enabling geo enrichment of the
145        /// HTTP (JSON) response. Typically built from `RAMA_IP_GEO_DB`.
146        #[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        /// define a tls server cert config to be used for tls terminaton
156        /// by the IP service.
157        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    /// build a tcp service ready to echo the client IP back
168    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)]
184/// The inner http ip-service used by the [`IpServiceBuilder`]. Mounted at
185/// `/` by the surrounding [`crate::http::service::web::Router`] in
186/// [`IpServiceBuilder::build_http`]; the asset sidecars are sibling
187/// routes on the same router.
188struct HttpIpService {
189    /// Optional geolocation database; when present, the JSON response is
190    /// enriched with the resolved location (merged + per-source).
191    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                        // attribution rides in the x-geo-attribution header, not the body
226                        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
236/// Sidecar stylesheet for the HTML page. Served as a separate route so
237/// the defence-in-depth CSP can keep `style-src 'self'` (blocking
238/// inline `<style>`) without breaking the page.
239const IP_STYLE_CSS: &str = include_str!("ip.css");
240
241/// Sidecar clipboard-copy script. Served separately for the same
242/// reason as [`IP_STYLE_CSS`] (`script-src 'self'`).
243const 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        // honour q-values: try the most-preferred media types first (stable
259        // sort, so equal-quality entries keep their header order)
260        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]
282/// The inner tcp echo-service used by the [`IpServiceBuilder`].
283struct 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    /// build a tcp service ready to echo client IP back
332    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            // Limit the body size to 1MB for requests
437            BodyLimitLayer::request_only(mib(1)),
438            #[cfg(any(feature = "rustls", feature = "boring"))]
439            maybe_tls_accept_layer,
440        );
441
442        // Defence-in-depth response headers for the HTML page (txt/json
443        // responses also get them β€” they're benign there and means
444        // any future widening of HTML emission is already covered).
445        // The page loads `/style/ip.css` and `/script/ip.js` from the
446        // same origin, no inline scripts/styles, no external requests:
447        // the strict-self baseline (banner image whitelisted in the
448        // shared helper) covers it.
449        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        // Attribution header, derived from the loaded databases' notices.
455        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        // Route the IP echo + its asset sidecars through a Router so we
461        // get clean method-aware matching (anything outside the three
462        // known routes redirects to `/`).
463        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        // Wrap in `Arc` because `Router` is not `Clone` and
491        // `HttpServer::service` requires a cloneable inner service so it
492        // can hand a copy to each connection's task.
493        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    //! operation modes of the ip service
500
501    #[derive(Debug, Clone)]
502    #[non_exhaustive]
503    /// Default mode of the Ip service, echo'ng the info back over http
504    pub struct Http;
505
506    #[derive(Debug, Clone)]
507    #[non_exhaustive]
508    /// Alternative mode of the Ip service, echo'ng the ip info over tcp
509    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    // attribution comment from the loaded databases; geo panel when resolved
520    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        // merged result + one card per source, laid out in a responsive grid
530        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    /// The IP value flows through `html!`'s escape pipeline, so even if a
613    /// future `IpAddr::Display` impl produced HTML-special chars they would
614    /// be neutralised. Verify the rendered page contains the expected IP
615    /// inside `<code>…</code>` and that the page chrome is well-formed.
616    #[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        // Copy button is wired by selector ID in the inline script.
624        assert!(out.contains(r#"id="copyBtn""#));
625    }
626
627    /// The aria-label attribute uses the `"aria-label" = …` syntax (since
628    /// `aria-label` is not a Rust ident). Pin the rendered output.
629    #[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    /// Regression guard against the bug audited 2026-05-18: the IP page
637    /// must reference its CSS and JS via `<link>` / `<script src>`
638    /// because the surrounding service applies `style-src 'self'` and
639    /// `script-src 'self'` β€” an inline `<style>` or `<script>` block
640    /// would be blocked at the browser.
641    #[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        // The renderer is allowed to emit a self-closing `<script src=...>`,
650        // but never an inline `<script>...JS...</script>` body.
651        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    /// When a location is resolved, the page renders a geo panel (merged +
666    /// per-source) and embeds the attribution as an HTML comment.
667    #[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), &notices).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        // attribution is an HTML comment, never visible structured data
690        assert!(
691            out.contains("<!-- This product includes GeoLite2"),
692            "attribution comment missing"
693        );
694
695        // …and absent when no database is configured
696        let plain = render_html_page(ip, None, &[]).into_string();
697        assert!(!plain.contains("Geolocation"));
698        assert!(!plain.contains("<!--"));
699    }
700}