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    extensions::ExtensionsRef,
17    http::{
18        Request, Response, StatusCode,
19        headers::exotic::XClacksOverhead,
20        headers::forwarded::{CFConnectingIp, ClientIp, TrueClientIp, XClientIp, XRealIp},
21        headers::{Accept, HeaderMapExt},
22        layer::{
23            forwarded::GetForwardedHeaderLayer, required_header::AddRequiredResponseHeadersLayer,
24            set_header::SetResponseHeaderLayer, trace::TraceLayer,
25        },
26        mime,
27        server::HttpServer,
28        service::web::response::{Html, IntoResponse, Json, Redirect},
29    },
30    io::Io,
31    layer::limit::policy::UnlimitedPolicy,
32    layer::{ConsumeErrLayer, LimitLayer, TimeoutLayer, limit::policy::ConcurrentPolicy},
33    net::forwarded::Forwarded,
34    net::stream::{SocketInfo, layer::http::BodyLimitLayer},
35    proxy::haproxy::server::HaProxyLayer,
36    rt::Executor,
37    tcp::TcpStream,
38    telemetry::tracing,
39};
40
41#[cfg(all(feature = "rustls", not(feature = "boring")))]
42use crate::tls::rustls::server::{TlsAcceptorData, TlsAcceptorLayer};
43
44#[cfg(any(feature = "rustls", feature = "boring"))]
45use crate::http::headers::StrictTransportSecurity;
46
47#[cfg(feature = "boring")]
48use crate::{
49    net::tls::server::ServerConfig,
50    tls::boring::server::{TlsAcceptorData, TlsAcceptorLayer},
51};
52
53#[cfg(feature = "boring")]
54type TlsConfig = ServerConfig;
55
56#[cfg(all(feature = "rustls", not(feature = "boring")))]
57type TlsConfig = TlsAcceptorData;
58
59use rama_core::error::{ErrorExt as _, extra::OpaqueError};
60use std::{convert::Infallible, marker::PhantomData, net::IpAddr, 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`].
185struct HttpIpService;
186
187impl Service<Request> for HttpIpService {
188    type Output = Response;
189    type Error = BoxError;
190
191    async fn serve(&self, req: Request) -> Result<Self::Output, Self::Error> {
192        let norm_req_path = req.uri().path().trim_matches('/');
193        if !norm_req_path.is_empty() {
194            tracing::debug!("unexpected request path '{norm_req_path}', redirect to root");
195            return Ok(Redirect::permanent("/").into_response());
196        }
197
198        let peer_ip = req
199            .extensions()
200            .get_ref::<Forwarded>()
201            .and_then(|f| f.client_ip())
202            .or_else(|| {
203                req.extensions()
204                    .get_ref::<SocketInfo>()
205                    .map(|s| s.peer_addr().ip_addr)
206            });
207
208        Ok(match peer_ip {
209            Some(ip) => match HttpBodyContentFormat::derive_from_req(&req) {
210                HttpBodyContentFormat::Txt => ip.to_string().into_response(),
211                HttpBodyContentFormat::Html => format_html_page(ip).into_response(),
212                HttpBodyContentFormat::Json => Json(serde_json::json!({
213                    "ip": ip,
214                }))
215                .into_response(),
216            },
217            None => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
218        })
219    }
220}
221
222#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
223enum HttpBodyContentFormat {
224    #[default]
225    Txt,
226    Html,
227    Json,
228}
229
230impl HttpBodyContentFormat {
231    fn derive_from_req(req: &Request) -> Self {
232        let Some(accept) = req.headers().typed_get::<Accept>() else {
233            return Self::default();
234        };
235        accept
236            .0
237            .iter()
238            .find_map(|qv| {
239                let r#type = qv.value.subtype();
240                if r#type == mime::JSON {
241                    Some(Self::Json)
242                } else if r#type == mime::HTML {
243                    Some(Self::Html)
244                } else if r#type == mime::TEXT {
245                    Some(Self::Txt)
246                } else {
247                    None
248                }
249            })
250            .unwrap_or_default()
251    }
252}
253
254#[derive(Debug, Clone)]
255#[non_exhaustive]
256/// The inner tcp echo-service used by the [`IpServiceBuilder`].
257struct TcpIpService;
258
259impl<Input> Service<Input> for TcpIpService
260where
261    Input: Io + Unpin + ExtensionsRef,
262{
263    type Output = ();
264    type Error = BoxError;
265
266    async fn serve(&self, stream: Input) -> Result<Self::Output, Self::Error> {
267        tracing::info!("connection received");
268        let peer_ip = stream
269            .extensions()
270            .get_ref::<Forwarded>()
271            .and_then(|f| f.client_ip())
272            .or_else(|| {
273                stream
274                    .extensions()
275                    .get_ref::<SocketInfo>()
276                    .map(|s| s.peer_addr().ip_addr)
277            });
278        let Some(peer_ip) = peer_ip else {
279            tracing::error!("missing peer information");
280            return Ok(());
281        };
282
283        let mut stream = std::pin::pin!(stream);
284
285        match peer_ip {
286            std::net::IpAddr::V4(ip) => {
287                if let Err(err) = stream.write_all(&ip.octets()).await {
288                    tracing::error!("error writing IPv4 of peer to peer: {}", err);
289                }
290            }
291            std::net::IpAddr::V6(ip) => {
292                if let Err(err) = stream.write_all(&ip.octets()).await {
293                    tracing::error!("error writing IPv6 of peer to peer: {}", err);
294                }
295            }
296        };
297
298        Ok(())
299    }
300}
301
302impl IpServiceBuilder<mode::Transport> {
303    #[allow(unused_mut)]
304    #[inline]
305    /// build a tcp service ready to echo client IP back
306    pub fn build(
307        mut self,
308    ) -> Result<impl Service<TcpStream, Output = (), Error = Infallible>, BoxError> {
309        #[cfg(all(feature = "rustls", not(feature = "boring")))]
310        let tls_cfg = self.tls_server_config.take();
311
312        #[cfg(feature = "boring")]
313        let tls_cfg: Option<TlsAcceptorData> = match self.tls_server_config.take() {
314            Some(cfg) => Some(cfg.try_into()?),
315            None => None,
316        };
317
318        #[cfg(any(feature = "rustls", feature = "boring"))]
319        {
320            let maybe_tls_acceptor_layer = tls_cfg.map(TlsAcceptorLayer::new);
321            self.build_tcp(maybe_tls_acceptor_layer)
322        }
323
324        #[cfg(not(any(feature = "rustls", feature = "boring")))]
325        self.build_tcp()
326    }
327}
328
329impl<M> IpServiceBuilder<M> {
330    fn build_tcp<S: Io + ExtensionsRef + Unpin + Sync>(
331        self,
332        #[cfg(any(feature = "rustls", feature = "boring"))] maybe_tls_accept_layer: Option<
333            TlsAcceptorLayer,
334        >,
335    ) -> Result<impl Service<S, Output = (), Error = Infallible>, BoxError> {
336        let tcp_forwarded_layer = match &self.forward {
337            None => None,
338            Some(ForwardKind::HaProxy) => Some(HaProxyLayer::default()),
339            Some(other) => {
340                return Err(OpaqueError::from_static_str(
341                    "invalid forward kind for Transport mode",
342                )
343                .with_context_debug_field("kind", || other.clone()));
344            }
345        };
346
347        let tcp_service_builder = (
348            ConsumeErrLayer::trace_as(tracing::Level::DEBUG),
349            LimitLayer::new(if self.concurrent_limit > 0 {
350                Either::A(ConcurrentPolicy::max(self.concurrent_limit))
351            } else {
352                Either::B(UnlimitedPolicy::new())
353            }),
354            if !self.timeout.is_zero() {
355                TimeoutLayer::new(self.timeout)
356            } else {
357                TimeoutLayer::never()
358            },
359            tcp_forwarded_layer,
360            #[cfg(any(feature = "rustls", feature = "boring"))]
361            maybe_tls_accept_layer,
362        );
363
364        Ok(tcp_service_builder.into_layer(TcpIpService))
365    }
366
367    fn build_http<S: Io + Unpin + Sync + ExtensionsRef>(
368        self,
369        executor: Executor,
370        #[cfg(any(feature = "rustls", feature = "boring"))] maybe_tls_accept_layer: Option<
371            TlsAcceptorLayer,
372        >,
373    ) -> Result<impl Service<S, Output = (), Error = Infallible>, BoxError> {
374        let (tcp_forwarded_layer, http_forwarded_layer) = match &self.forward {
375            None => (None, None),
376            Some(ForwardKind::Forwarded) => {
377                (None, Some(Either7::A(GetForwardedHeaderLayer::forwarded())))
378            }
379            Some(ForwardKind::XForwardedFor) => (
380                None,
381                Some(Either7::B(GetForwardedHeaderLayer::x_forwarded_for())),
382            ),
383            Some(ForwardKind::XClientIp) => (
384                None,
385                Some(Either7::C(GetForwardedHeaderLayer::<XClientIp>::new())),
386            ),
387            Some(ForwardKind::ClientIp) => (
388                None,
389                Some(Either7::D(GetForwardedHeaderLayer::<ClientIp>::new())),
390            ),
391            Some(ForwardKind::XRealIp) => (
392                None,
393                Some(Either7::E(GetForwardedHeaderLayer::<XRealIp>::new())),
394            ),
395            Some(ForwardKind::CFConnectingIp) => (
396                None,
397                Some(Either7::F(GetForwardedHeaderLayer::<CFConnectingIp>::new())),
398            ),
399            Some(ForwardKind::TrueClientIp) => (
400                None,
401                Some(Either7::G(GetForwardedHeaderLayer::<TrueClientIp>::new())),
402            ),
403            Some(ForwardKind::HaProxy) => (Some(HaProxyLayer::default()), None),
404        };
405
406        #[cfg(any(feature = "rustls", feature = "boring"))]
407        let hsts_layer = maybe_tls_accept_layer.is_some().then(|| {
408            SetResponseHeaderLayer::if_not_present_typed(
409                StrictTransportSecurity::excluding_subdomains_for_max_seconds(31536000),
410            )
411        });
412
413        let tcp_service_builder = (
414            ConsumeErrLayer::trace_as(tracing::Level::DEBUG),
415            (self.concurrent_limit > 0)
416                .then(|| LimitLayer::new(ConcurrentPolicy::max(self.concurrent_limit))),
417            (!self.timeout.is_zero()).then(|| TimeoutLayer::new(self.timeout)),
418            tcp_forwarded_layer,
419            // Limit the body size to 1MB for requests
420            BodyLimitLayer::request_only(1024 * 1024),
421            #[cfg(any(feature = "rustls", feature = "boring"))]
422            maybe_tls_accept_layer,
423        );
424
425        let http_service = (
426            TraceLayer::new_for_http(),
427            SetResponseHeaderLayer::<XClacksOverhead>::if_not_present_default_typed(),
428            AddRequiredResponseHeadersLayer::default(),
429            ConsumeErrLayer::default(),
430            #[cfg(any(feature = "rustls", feature = "boring"))]
431            hsts_layer,
432            http_forwarded_layer,
433        )
434            .into_layer(HttpIpService);
435
436        Ok(tcp_service_builder.into_layer(HttpServer::auto(executor).service(http_service)))
437    }
438}
439
440pub mod mode {
441    //! operation modes of the ip service
442
443    #[derive(Debug, Clone)]
444    #[non_exhaustive]
445    /// Default mode of the Ip service, echo'ng the info back over http
446    pub struct Http;
447
448    #[derive(Debug, Clone)]
449    #[non_exhaustive]
450    /// Alternative mode of the Ip service, echo'ng the ip info over tcp
451    pub struct Transport;
452}
453
454fn format_html_page(ip: IpAddr) -> Html<String> {
455    Html(format!(
456        r##"<!doctype html> <html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width,initial-scale=1" /> <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='0.9em' font-size='90'>🦙</text></svg>" /> <title>Rama IP</title> <style> *, *::before, *::after {{ box-sizing: border-box; }} :root{{ --bg:#000; --panel:#0f0f0f; --green:#45d23a; --muted:#bfbfbf; }} html,body{{height:100%;margin:0;font-family:system-ui,-apple-system,Segoe UI,Roboto,"Helvetica Neue",Arial;}} body{{ background:var(--bg); color:var(--muted); display:flex; align-items:center; justify-content:center; padding:2.8rem; }} .card{{ text-align:center; }} .logo{{ display:flex; align-items:center; justify-content:center; gap:0.8rem; margin-bottom:1.1rem; }} .logo, .logo a, .logo a:hover {{ color:var(--green); font-weight:700; font-size:2rem; letter-spacing:0.4rem; }} .logo a {{ text-decoration: none; }} .logo a:hover {{ text-decoration: underline; }} .subtitle{{ font-size:1.1rem; margin:0.3rem 0 2rem 0; color:var(--muted); }} .panel{{ background:linear-gradient(180deg,#0b0b0b 0%, #111 100%); border-radius:0.8rem; padding:2rem; box-shadow:0 0.3rem 2rem rgba(0,0,0,0.7), inset 0 0.05rem 0 rgba(255,255,255,0.02); border:0.1rem solid rgba(69,210,58,0.06); }} .ip{{ background:transparent; border-radius:0.6rem; padding:1rem 1.1rem; font-family: ui-monospace,SFMono-Regular,Menlo,monospace; font-size:1.1rem; color:#fff139; margin:0.6rem auto 1.1rem auto; word-break:break-all; border:0.05rem solid rgba(69,210,58,0.12); }} .muted{{ color:var(--muted); font-size:1rem; margin-bottom:0.9rem; }} .controls{{display:flex;gap:0.8rem;justify-content:center;flex-wrap:wrap;}} button{{ background:transparent; color:var(--green); padding:0.8rem 1.1rem; border-radius:0.6rem; font-weight:700; border:0.1rem solid rgba(69,210,58,0.9); cursor:pointer; }} button.primary{{ background:var(--green); color:#032; box-shadow:0 0.4rem 1.2rem rgba(69,210,58,0.08); }} .note{{font-size:0.95rem;color:#9aa; margin-top:1rem;}} .small{{font-size:0.9rem;color:#808080;margin-top:0.7rem}} </style> </head> <body> <div class="card"> <div class="logo"> <div>🦙</div> <div><a href="https://ramaproxy.org">ラマ</a></div> </div> <div class="panel" role="region" aria-label="ip panel"> <div class="muted">Your public ip</div><div id="ip" class="ip"> <code>{ip}</code> </div> <div class="controls"> <button id="copyBtn" class="primary" title="Copy ip to clipboard">📋 Copy IP</button></div> </div> <script> (async function(){{ const ipEl = document.getElementById('ip'); const copyBtn = document.getElementById('copyBtn'); copyBtn.addEventListener('click', async ()=>{{ const txt = ipEl.textContent.trim(); try{{ await navigator.clipboard.writeText(txt); copyBtn.textContent = 'Copied'; setTimeout(()=> copyBtn.textContent = 'Copy IP', 1400); }}catch(e){{ const ta = document.createElement('textarea'); ta.value = txt; document.body.appendChild(ta); ta.select(); try{{ document.execCommand('copy'); copyBtn.textContent = 'Copied'; }} catch(e){{ alert('Copy failed. Select and copy manually.'); }} ta.remove(); setTimeout(()=> copyBtn.textContent = 'Copy IP', 1400); }} }}); }})(); </script> </body> </html>"##,
457    ))
458}