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