1#![expect(
6 clippy::allow_attributes,
7 reason = "feature-gated `mut self` consumed by some cfg branches but not others β `#[allow(unused_mut)]` would warn unfulfilled in the cfg arm where it IS used"
8)]
9
10use std::{convert::Infallible, marker::PhantomData, net::IpAddr, sync::Arc, time::Duration};
11
12use tokio::io::AsyncWriteExt;
13
14use crate::{
15 Layer, Service,
16 cli::{DEFAULT_CONNECTION_GRACE, ForwardKind, connection_scope},
17 combinators::{Either, Either3, Either7},
18 error::{BoxError, BoxErrorExt, ErrorExt as _},
19 extensions::ExtensionsRef,
20 graceful::WeakScope,
21 http::{
22 BodyLimitLayer, Request, Response, StatusCode, Version,
23 headers::{
24 Accept, AltSvc, HeaderMapExt,
25 exotic::XClacksOverhead,
26 forwarded::{CFConnectingIp, ClientIp, TrueClientIp, XClientIp, XRealIp},
27 },
28 layer::{
29 forwarded::GetForwardedHeaderLayer, required_header::AddRequiredResponseHeadersLayer,
30 set_header::SetResponseHeaderLayer, trace::TraceLayer,
31 },
32 mime,
33 server::HttpServer,
34 service::web::response::{Css, IntoResponse, Json, Redirect, Script},
35 },
36 io::Io,
37 layer::{
38 ConsumeErrLayer, LimitLayer, ScopeLayer,
39 limit::policy::{ConcurrentPolicy, RateLimitReached, RatePolicy, UnlimitedPolicy},
40 },
41 net::{
42 address::ip::geo::{GeoLocation, IpGeoDb, IpGeoInfo},
43 stream::layer::{ThrottleLayer, ThrottleMode},
44 },
45 proxy::haproxy::server::HaProxyLayer,
46 tcp::TcpStream,
47 telemetry::tracing,
48 utils::{octets::mib, rate::Rate},
49};
50
51#[cfg(feature = "quic")]
52use crate::quic;
53
54core::cfg_select! {
55 feature = "boring" => {
56 use crate::tls::boring::server::TlsAcceptorLayer;
57 }
58 feature = "rustls" => {
59 use crate::tls::rustls::server::TlsAcceptorLayer;
60 }
61 _ => {}
62}
63
64#[cfg(any(feature = "rustls", feature = "boring"))]
65use crate::{http::headers::StrictTransportSecurity, tls::server::TlsServerConfig};
66
67#[derive(Debug, Clone)]
68pub struct IpServiceBuilder<M> {
71 #[cfg(any(feature = "rustls", feature = "boring"))]
72 tls_server_config: Option<TlsServerConfig>,
73 concurrent_limit: usize,
74 rate_limit: Option<Rate>,
75 throttle: Option<Rate>,
76 timeout: Duration,
77 timeout_grace: Duration,
78 forward: Option<ForwardKind>,
79 geo_db: Option<Arc<IpGeoDb>>,
80 http_version: Option<Version>,
81 alt_svc: Option<AltSvc>,
82 _mode: PhantomData<fn(M)>,
83}
84
85impl IpServiceBuilder<mode::Http> {
86 #[must_use]
88 pub fn http() -> Self {
89 Self {
90 #[cfg(any(feature = "rustls", feature = "boring"))]
91 tls_server_config: None,
92 concurrent_limit: 0,
93 rate_limit: None,
94 throttle: None,
95 timeout: Duration::ZERO,
96 timeout_grace: DEFAULT_CONNECTION_GRACE,
97 forward: None,
98 geo_db: None,
99 http_version: None,
100 alt_svc: None,
101 _mode: PhantomData,
102 }
103 }
104}
105
106impl IpServiceBuilder<mode::Transport> {
107 #[must_use]
109 pub fn tcp() -> Self {
110 Self {
111 #[cfg(any(feature = "rustls", feature = "boring"))]
112 tls_server_config: None,
113 concurrent_limit: 0,
114 rate_limit: None,
115 throttle: None,
116 timeout: Duration::ZERO,
117 timeout_grace: DEFAULT_CONNECTION_GRACE,
118 forward: None,
119 geo_db: None,
120 http_version: None,
121 alt_svc: None,
122 _mode: PhantomData,
123 }
124 }
125}
126
127impl<M> IpServiceBuilder<M> {
128 crate::utils::macros::generate_set_and_with! {
129 #[must_use]
131 pub fn concurrent(mut self, limit: usize) -> Self {
132 self.concurrent_limit = limit;
133 self
134 }
135 }
136
137 crate::utils::macros::generate_set_and_with! {
138 #[must_use]
142 pub fn rate_limit(mut self, rate: Option<Rate>) -> Self {
143 self.rate_limit = rate;
144 self
145 }
146 }
147
148 crate::utils::macros::generate_set_and_with! {
149 #[must_use]
152 pub fn throttle(mut self, rate: Option<Rate>) -> Self {
153 self.throttle = rate;
154 self
155 }
156 }
157
158 crate::utils::macros::generate_set_and_with! {
159 #[must_use]
164 pub fn timeout(mut self, timeout: Duration) -> Self {
165 self.timeout = timeout;
166 self
167 }
168 }
169
170 crate::utils::macros::generate_set_and_with! {
171 pub fn timeout_grace(mut self, timeout_grace: Duration) -> Self {
176 self.timeout_grace = timeout_grace;
177 self
178 }
179 }
180
181 crate::utils::macros::generate_set_and_with! {
182 #[must_use]
194 pub fn forward(mut self, maybe_kind: Option<ForwardKind>) -> Self {
195 self.forward = maybe_kind;
196 self
197 }
198 }
199
200 crate::utils::macros::generate_set_and_with! {
201 #[must_use]
204 pub fn geo_db(mut self, db: Option<Arc<IpGeoDb>>) -> Self {
205 self.geo_db = db;
206 self
207 }
208 }
209
210 crate::utils::macros::generate_set_and_with! {
211 #[cfg(any(feature = "rustls", feature = "boring"))]
212 pub fn tls_server_config(mut self, cfg: Option<TlsServerConfig>) -> Self {
215 self.tls_server_config = cfg;
216 self
217 }
218 }
219}
220
221impl IpServiceBuilder<mode::Http> {
222 crate::utils::macros::generate_set_and_with! {
223 #[must_use]
225 pub fn http_version(mut self, version: Option<Version>) -> Self {
226 self.http_version = version;
227 self
228 }
229 }
230
231 crate::utils::macros::generate_set_and_with! {
232 #[must_use]
234 pub fn alt_svc(mut self, alt_svc: Option<AltSvc>) -> Self {
235 self.alt_svc = alt_svc;
236 self
237 }
238 }
239
240 #[inline]
241 pub fn build(
243 self,
244 scope: impl Into<WeakScope>,
245 ) -> Result<impl Service<TcpStream, Output = (), Error = Infallible>, BoxError> {
246 let scope = scope.into();
247 self.build_services(scope).map(|(tcp, _)| tcp)
248 }
249
250 #[cfg(feature = "quic")]
257 #[cfg_attr(docsrs, doc(cfg(feature = "quic")))]
258 pub fn build_with_http3(
259 self,
260 scope: impl Into<WeakScope>,
261 ) -> Result<
262 (
263 impl Service<TcpStream, Output = (), Error = Infallible>,
264 impl Service<quic::Connection, Output = (), Error = Infallible>,
265 ),
266 BoxError,
267 > {
268 let scope = scope.into();
269 self.build_services(scope)
270 }
271
272 fn build_services(
273 self,
274 scope: WeakScope,
275 ) -> Result<
276 (
277 impl Service<TcpStream, Output = (), Error = Infallible>,
278 impl super::MaybeHttp3Service,
279 ),
280 BoxError,
281 > {
282 #[cfg(any(feature = "rustls", feature = "boring"))]
283 let maybe_tls_accept_layer = self.tls_server_config.map(TlsAcceptorLayer::new);
284
285 let (tcp_forwarded_layer, http_forwarded_layer) = match &self.forward {
286 None => (None, None),
287 Some(ForwardKind::Forwarded) => {
288 (None, Some(Either7::A(GetForwardedHeaderLayer::forwarded())))
289 }
290 Some(ForwardKind::XForwardedFor) => (
291 None,
292 Some(Either7::B(GetForwardedHeaderLayer::x_forwarded_for())),
293 ),
294 Some(ForwardKind::XClientIp) => (
295 None,
296 Some(Either7::C(GetForwardedHeaderLayer::<XClientIp>::new())),
297 ),
298 Some(ForwardKind::ClientIp) => (
299 None,
300 Some(Either7::D(GetForwardedHeaderLayer::<ClientIp>::new())),
301 ),
302 Some(ForwardKind::XRealIp) => (
303 None,
304 Some(Either7::E(GetForwardedHeaderLayer::<XRealIp>::new())),
305 ),
306 Some(ForwardKind::CFConnectingIp) => (
307 None,
308 Some(Either7::F(GetForwardedHeaderLayer::<CFConnectingIp>::new())),
309 ),
310 Some(ForwardKind::TrueClientIp) => (
311 None,
312 Some(Either7::G(GetForwardedHeaderLayer::<TrueClientIp>::new())),
313 ),
314 Some(ForwardKind::HaProxy) => (Some(HaProxyLayer::default()), None),
315 };
316
317 #[cfg(any(feature = "rustls", feature = "boring"))]
318 let hsts_layer = maybe_tls_accept_layer.is_some().then(|| {
319 SetResponseHeaderLayer::if_not_present_typed(
320 StrictTransportSecurity::excluding_subdomains_for_max_seconds(31536000),
321 )
322 });
323
324 let connection_limit = (self.concurrent_limit > 0)
325 .then(|| LimitLayer::new(ConcurrentPolicy::max(self.concurrent_limit)));
326 let connection_timeout = connection_scope(&scope, self.timeout, self.timeout_grace);
327 let throttle = self
329 .throttle
330 .map(|rate| ThrottleLayer::symmetric(ThrottleMode::per_conn(rate)));
331 let tcp_service_builder = (
332 ConsumeErrLayer::trace_as(tracing::Level::DEBUG),
333 connection_limit.clone(),
334 connection_timeout.clone(),
335 throttle.clone(),
336 tcp_forwarded_layer,
337 BodyLimitLayer::request_only(mib(1)),
339 #[cfg(any(feature = "rustls", feature = "boring"))]
340 maybe_tls_accept_layer,
341 );
342
343 let (csp_layer, nosniff_layer, referrer_layer, frame_layer) =
351 crate::cli::service::http_security::defence_in_depth_layer(
352 crate::cli::service::http_security::rama_html_csp(),
353 );
354
355 let geo_attribution = self.geo_db.as_ref().and_then(|db| {
357 let notices: Vec<_> = db.attributions().collect();
358 (!notices.is_empty()).then(|| crate::cli::service::geo::geo_attribution_layer(notices))
359 });
360
361 let router = crate::http::service::web::Router::new()
365 .with_get(
366 "/",
367 HttpIpService {
368 geo_db: self.geo_db,
369 },
370 )
371 .with_get("/style/ip.css", Css(IP_STYLE_CSS))
372 .with_get("/script/ip.js", Script(IP_SCRIPT_JS))
373 .with_not_found(async || Redirect::permanent("/"));
374
375 let http_service = (
376 TraceLayer::new_for_http(),
377 SetResponseHeaderLayer::<XClacksOverhead>::if_not_present_default_typed(),
378 self.alt_svc
379 .map(SetResponseHeaderLayer::if_not_present_typed),
380 AddRequiredResponseHeadersLayer::default(),
381 self.rate_limit.map(|rate| {
382 LimitLayer::new(RatePolicy::abort(rate)).with_error_into_response_fn(
383 |err: RateLimitReached| Ok::<_, Infallible>(err.into_response()),
384 )
385 }),
386 geo_attribution,
387 csp_layer,
388 nosniff_layer,
389 referrer_layer,
390 frame_layer,
391 ConsumeErrLayer::default(),
392 #[cfg(any(feature = "rustls", feature = "boring"))]
393 hsts_layer,
394 http_forwarded_layer,
395 )
396 .into_layer(router);
397
398 let http_service = Arc::new(http_service);
402 let http_transport_service = match self.http_version {
403 None => Either3::A(HttpServer::auto(scope.clone()).service(http_service.clone())),
404 Some(Version::HTTP_2) => {
405 Either3::B(HttpServer::new_h2(scope.clone()).service(http_service.clone()))
406 }
407 Some(Version::HTTP_11 | Version::HTTP_10 | Version::HTTP_09) => {
408 Either3::C(HttpServer::new_http1(scope.clone()).service(http_service.clone()))
409 }
410 Some(version) => {
411 return Err(BoxError::from_static_str("unsupported http version")
412 .context_debug_field("version", version));
413 }
414 };
415 #[cfg(feature = "quic")]
416 let http3_service = (
417 ConsumeErrLayer::trace_as(tracing::Level::DEBUG),
418 connection_limit,
419 connection_timeout,
420 throttle,
421 BodyLimitLayer::request_only(mib(1)),
422 )
423 .into_layer(HttpServer::new_http3(scope).service(http_service));
424 #[cfg(not(feature = "quic"))]
425 let http3_service = (
426 connection_limit,
427 connection_timeout,
428 throttle,
429 http_service,
430 scope,
431 );
432
433 Ok((
434 tcp_service_builder.into_layer(http_transport_service),
435 http3_service,
436 ))
437 }
438}
439
440#[derive(Debug, Clone)]
441struct HttpIpService {
446 geo_db: Option<Arc<IpGeoDb>>,
449}
450
451impl Service<Request> for HttpIpService {
452 type Output = Response;
453 type Error = Infallible;
454
455 async fn serve(&self, req: Request) -> Result<Self::Output, Self::Error> {
456 let peer_ip = crate::net::client_ip::client_ip(&req);
457
458 Ok(match peer_ip {
459 Some(ip) => match HttpBodyContentFormat::derive_from_req(&req) {
460 HttpBodyContentFormat::Txt => ip.to_string().into_response(),
461 HttpBodyContentFormat::Html => {
462 let geo = self.geo_db.as_ref().and_then(|db| db.resolve(ip));
463 let attributions: Vec<_> = self
464 .geo_db
465 .as_ref()
466 .map(|db| db.attributions().collect())
467 .unwrap_or_default();
468 render_html_page(ip, geo.as_ref(), &attributions).into_response()
469 }
470 HttpBodyContentFormat::Json => {
471 let geo = self.geo_db.as_ref().and_then(|db| db.resolve(ip));
472 let mut body = serde_json::json!({ "ip": ip });
473 if let Some(info) = geo {
474 body["geo"] = serde_json::to_value(&info).unwrap_or_default();
476 }
477 Json(body).into_response()
478 }
479 },
480 None => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
481 })
482 }
483}
484
485const IP_STYLE_CSS: &str = include_str!("ip.css");
489
490const IP_SCRIPT_JS: &str = include_str!("ip.js");
493
494#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
495enum HttpBodyContentFormat {
496 #[default]
497 Txt,
498 Html,
499 Json,
500}
501
502impl HttpBodyContentFormat {
503 fn derive_from_req(req: &Request) -> Self {
504 let Some(accept) = req.headers().typed_get::<Accept>() else {
505 return Self::default();
506 };
507 let mut entries: Vec<_> = accept.0.iter().collect();
510 entries.sort_by_key(|qv| std::cmp::Reverse(qv.quality));
511 entries
512 .into_iter()
513 .find_map(|qv| {
514 let r#type = qv.value.subtype();
515 if r#type == mime::JSON {
516 Some(Self::Json)
517 } else if r#type == mime::HTML {
518 Some(Self::Html)
519 } else if r#type == mime::TEXT {
520 Some(Self::Txt)
521 } else {
522 None
523 }
524 })
525 .unwrap_or_default()
526 }
527}
528
529#[derive(Debug, Clone)]
530#[non_exhaustive]
531struct TcpIpService;
533
534impl<Input> Service<Input> for TcpIpService
535where
536 Input: Io + Unpin + ExtensionsRef,
537{
538 type Output = ();
539 type Error = BoxError;
540
541 async fn serve(&self, stream: Input) -> Result<Self::Output, Self::Error> {
542 tracing::info!("connection received");
543 let peer_ip = crate::net::client_ip::client_ip(&stream);
544 let Some(peer_ip) = peer_ip else {
545 tracing::error!("missing peer information");
546 return Ok(());
547 };
548
549 let mut stream = std::pin::pin!(stream);
550
551 match peer_ip {
552 std::net::IpAddr::V4(ip) => {
553 if let Err(err) = stream.write_all(&ip.octets()).await {
554 tracing::error!("error writing IPv4 of peer to peer: {}", err);
555 }
556 }
557 std::net::IpAddr::V6(ip) => {
558 if let Err(err) = stream.write_all(&ip.octets()).await {
559 tracing::error!("error writing IPv6 of peer to peer: {}", err);
560 }
561 }
562 };
563
564 Ok(())
565 }
566}
567
568impl IpServiceBuilder<mode::Transport> {
569 #[allow(unused_mut)]
570 #[inline]
571 pub fn build(
573 mut self,
574 scope: impl Into<WeakScope>,
575 ) -> Result<impl Service<TcpStream, Output = (), Error = Infallible>, BoxError> {
576 let connection_scope = connection_scope(&scope.into(), self.timeout, self.timeout_grace);
577 #[cfg(any(feature = "rustls", feature = "boring"))]
578 {
579 let maybe_tls_acceptor_layer = self.tls_server_config.take().map(TlsAcceptorLayer::new);
580 self.build_tcp(connection_scope, maybe_tls_acceptor_layer)
581 }
582
583 #[cfg(not(any(feature = "rustls", feature = "boring")))]
584 self.build_tcp(connection_scope)
585 }
586}
587
588impl<M> IpServiceBuilder<M> {
589 fn build_tcp<S: Io + ExtensionsRef + Unpin + Sync>(
590 self,
591 connection_scope: ScopeLayer,
592 #[cfg(any(feature = "rustls", feature = "boring"))] maybe_tls_accept_layer: Option<
593 TlsAcceptorLayer,
594 >,
595 ) -> Result<impl Service<S, Output = (), Error = Infallible>, BoxError> {
596 let tcp_forwarded_layer = match &self.forward {
597 None => None,
598 Some(ForwardKind::HaProxy) => Some(HaProxyLayer::default()),
599 Some(other) => {
600 return Err(
601 BoxError::from_static_str("invalid forward kind for Transport mode")
602 .with_context_debug_field("kind", || other.clone()),
603 );
604 }
605 };
606
607 let tcp_service_builder = (
608 ConsumeErrLayer::trace_as(tracing::Level::DEBUG),
609 self.rate_limit
610 .map(|rate| LimitLayer::new(RatePolicy::abort(rate))),
611 LimitLayer::new(if self.concurrent_limit > 0 {
612 Either::A(ConcurrentPolicy::max(self.concurrent_limit))
613 } else {
614 Either::B(UnlimitedPolicy::new())
615 }),
616 connection_scope,
617 self.throttle
618 .map(|rate| ThrottleLayer::symmetric(ThrottleMode::per_conn(rate))),
619 tcp_forwarded_layer,
620 #[cfg(any(feature = "rustls", feature = "boring"))]
621 maybe_tls_accept_layer,
622 );
623
624 Ok(tcp_service_builder.into_layer(TcpIpService))
625 }
626}
627
628pub mod mode {
629 #[derive(Debug, Clone)]
632 #[non_exhaustive]
633 pub struct Http;
635
636 #[derive(Debug, Clone)]
637 #[non_exhaustive]
638 pub struct Transport;
640}
641
642fn render_html_page(
643 ip: IpAddr,
644 geo: Option<&IpGeoInfo>,
645 attributions: &[&str],
646) -> impl crate::http::protocols::html::IntoHtml + IntoResponse {
647 use crate::http::protocols::html::*;
648
649 let geo_comment =
651 crate::cli::service::geo::geo_attribution_html_comment(attributions).map(PreEscaped);
652 let geo_panel = geo.map(|info| {
653 let rows = |loc: &GeoLocation| {
654 crate::cli::service::geo::geo_location_rows(loc)
655 .into_iter()
656 .map(|(k, v)| div!(class = "georow", div!(class = "muted", k), div!(code!(v))))
657 .collect::<Vec<_>>()
658 };
659 let card = |label: String, loc: &GeoLocation| {
661 div!(
662 class = "panel geo-card",
663 div!(class = "muted geo-source", label),
664 rows(loc),
665 )
666 };
667 let mut cards = vec![card("merged".to_owned(), &info.location)];
668 cards.extend(
669 info.by_source
670 .iter()
671 .map(|src| card(src.label.to_string(), &src.location)),
672 );
673 div!(
674 class = "geo-section",
675 role = "region",
676 "aria-label" = "geo panel",
677 div!(class = "muted geo-title", "Geolocation"),
678 div!(class = "geo-grid", cards),
679 )
680 });
681
682 html!(
683 lang = "en",
684 head!(
685 meta!(charset = "utf-8"),
686 meta!(
687 name = "viewport",
688 content = "width=device-width,initial-scale=1"
689 ),
690 link!(
691 rel = "icon",
692 href = PreEscaped(
693 "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'>\
694 <text y='0.9em' font-size='90'>π¦</text></svg>"
695 ),
696 ),
697 title!("Rama IP"),
698 link!(
699 rel = "stylesheet",
700 r#type = "text/css",
701 href = "/style/ip.css"
702 ),
703 ),
704 body!(
705 geo_comment,
706 div!(
707 class = "card",
708 div!(
709 class = "logo",
710 div!("π¦"),
711 div!(a!(href = "https://ramaproxy.org", "γ©γ")),
712 ),
713 div!(
714 class = "panel",
715 role = "region",
716 "aria-label" = "ip panel",
717 div!(class = "muted", "Your public ip"),
718 div!(id = "ip", class = "ip", code!(ip.to_string())),
719 div!(
720 class = "controls",
721 button!(
722 id = "copyBtn",
723 class = "primary",
724 title = "Copy ip to clipboard",
725 "π Copy IP",
726 ),
727 ),
728 ),
729 geo_panel,
730 script!(src = "/script/ip.js"),
731 )
732 ),
733 )
734}
735
736#[cfg(test)]
737mod render_html_page_tests {
738 use std::net::Ipv4Addr;
739
740 use super::*;
741 use crate::http::protocols::html::IntoHtml as _;
742
743 #[test]
748 fn render_html_page_embeds_ip_safely() {
749 let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
750 let out = render_html_page(ip, None, &[]).into_string();
751 assert!(out.starts_with("<!DOCTYPE html><html lang=\"en\">"));
752 assert!(out.contains("<title>Rama IP</title>"));
753 assert!(out.contains(r#"<div id="ip" class="ip"><code>127.0.0.1</code></div>"#));
754 assert!(out.contains(r#"id="copyBtn""#));
756 }
757
758 #[test]
761 fn render_html_page_emits_aria_label() {
762 let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 0, 1));
763 let out = render_html_page(ip, None, &[]).into_string();
764 assert!(out.contains(r#"aria-label="ip panel""#));
765 }
766
767 #[test]
773 fn render_html_page_uses_external_assets() {
774 let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
775 let out = render_html_page(ip, None, &[]).into_string();
776 assert!(
777 !out.contains("<style>") && !out.contains("<style "),
778 "IP page must not embed inline <style>; CSP blocks it"
779 );
780 assert!(
783 !out.contains("<script>"),
784 "IP page must not embed inline <script>; CSP blocks it"
785 );
786 assert!(
787 out.contains(r#"<link rel="stylesheet" type="text/css" href="/style/ip.css">"#),
788 "IP page must link to /style/ip.css",
789 );
790 assert!(
791 out.contains(r#"<script src="/script/ip.js">"#),
792 "IP page must source /script/ip.js",
793 );
794 }
795
796 #[test]
799 fn render_html_page_renders_geo_panel() {
800 use crate::{
801 geo::Country,
802 net::address::ip::geo::{GeoLocation, IpGeoInfo, IpGeoSourceResult},
803 };
804 let ip = IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4));
805 let loc = GeoLocation {
806 country: Some(Country::Belgium),
807 ..Default::default()
808 };
809 let info = IpGeoInfo {
810 ip,
811 location: loc.clone(),
812 by_source: vec![IpGeoSourceResult {
813 label: "geolite2".into(),
814 location: loc,
815 }],
816 };
817 let notices = ["This product includes GeoLite2 data created by MaxMind"];
818 let out = render_html_page(ip, Some(&info), ¬ices).into_string();
819 assert!(out.contains("Geolocation"), "geo panel title missing");
820 assert!(out.contains("Belgium"), "resolved country missing");
821 assert!(out.contains("geolite2"), "per-source label missing");
822 assert!(
824 out.contains("<!-- This product includes GeoLite2"),
825 "attribution comment missing"
826 );
827
828 let plain = render_html_page(ip, None, &[]).into_string();
830 assert!(!plain.contains("Geolocation"));
831 assert!(!plain.contains("<!--"));
832 }
833}