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