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