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