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
5use crate::{
6    Layer, Service,
7    cli::ForwardKind,
8    combinators::Either7,
9    error::{BoxError, OpaqueError},
10    extensions::{ExtensionsMut, ExtensionsRef},
11    http::{
12        Request, Response, StatusCode,
13        headers::forwarded::{CFConnectingIp, ClientIp, TrueClientIp, XClientIp, XRealIp},
14        headers::{Accept, HeaderMapExt},
15        layer::{
16            forwarded::GetForwardedHeaderLayer, required_header::AddRequiredResponseHeadersLayer,
17            trace::TraceLayer,
18        },
19        mime,
20        server::HttpServer,
21        service::web::response::{Html, IntoResponse, Json, Redirect},
22    },
23    layer::{ConsumeErrLayer, LimitLayer, TimeoutLayer, limit::policy::ConcurrentPolicy},
24    net::forwarded::Forwarded,
25    net::stream::{SocketInfo, layer::http::BodyLimitLayer},
26    proxy::haproxy::server::HaProxyLayer,
27    rt::Executor,
28    stream::Stream,
29    tcp::TcpStream,
30    telemetry::tracing,
31};
32
33#[cfg(all(feature = "rustls", not(feature = "boring")))]
34use crate::tls::rustls::server::{TlsAcceptorData, TlsAcceptorLayer};
35
36#[cfg(any(feature = "rustls", feature = "boring"))]
37use crate::http::{headers::StrictTransportSecurity, layer::set_header::SetResponseHeaderLayer};
38
39#[cfg(feature = "boring")]
40use crate::{
41    net::tls::server::ServerConfig,
42    tls::boring::server::{TlsAcceptorData, TlsAcceptorLayer},
43};
44
45#[cfg(feature = "boring")]
46type TlsConfig = ServerConfig;
47
48#[cfg(all(feature = "rustls", not(feature = "boring")))]
49type TlsConfig = TlsAcceptorData;
50
51use std::{convert::Infallible, marker::PhantomData, net::IpAddr, time::Duration};
52use tokio::io::AsyncWriteExt;
53
54#[derive(Debug, Clone)]
55/// Builder that can be used to run your own ip [`Service`],
56/// echo'ing back the client IP over http or tcp.
57pub struct IpServiceBuilder<M> {
58    #[cfg(any(feature = "rustls", feature = "boring"))]
59    tls_server_config: Option<TlsConfig>,
60    concurrent_limit: usize,
61    timeout: Duration,
62    forward: Option<ForwardKind>,
63    _mode: PhantomData<fn(M)>,
64}
65
66impl IpServiceBuilder<mode::Http> {
67    /// Create a new [`IpServiceBuilder`], echoing the IP back over L4.
68    #[must_use]
69    pub fn http() -> Self {
70        Self {
71            #[cfg(any(feature = "rustls", feature = "boring"))]
72            tls_server_config: None,
73            concurrent_limit: 0,
74            timeout: Duration::ZERO,
75            forward: None,
76            _mode: PhantomData,
77        }
78    }
79}
80
81impl IpServiceBuilder<mode::Transport> {
82    /// Create a new [`IpServiceBuilder`], echoing the IP back over L4.
83    #[must_use]
84    pub fn tcp() -> Self {
85        Self {
86            #[cfg(any(feature = "rustls", feature = "boring"))]
87            tls_server_config: None,
88            concurrent_limit: 0,
89            timeout: Duration::ZERO,
90            forward: None,
91            _mode: PhantomData,
92        }
93    }
94}
95
96impl<M> IpServiceBuilder<M> {
97    crate::utils::macros::generate_set_and_with! {
98        /// set the number of concurrent connections to allow
99        #[must_use]
100        pub fn concurrent(mut self, limit: usize) -> Self {
101            self.concurrent_limit = limit;
102            self
103        }
104    }
105
106    crate::utils::macros::generate_set_and_with! {
107        /// set the timeout in seconds for each connection
108        #[must_use]
109        pub fn timeout(mut self, timeout: Duration) -> Self {
110            self.timeout = timeout;
111            self
112        }
113    }
114
115    crate::utils::macros::generate_set_and_with! {
116        /// maybe enable support for one of the following "forward" headers or protocols
117        ///
118        /// Supported headers:
119        ///
120        /// Forwarded ("for="), X-Forwarded-For
121        ///
122        /// X-Client-IP Client-IP, X-Real-IP
123        ///
124        /// CF-Connecting-IP, True-Client-IP
125        ///
126        /// Or using HaProxy protocol.
127        #[must_use]
128        pub fn forward(mut self, maybe_kind: Option<ForwardKind>) -> Self {
129            self.forward = maybe_kind;
130            self
131        }
132    }
133
134    crate::utils::macros::generate_set_and_with! {
135        #[cfg(any(feature = "rustls", feature = "boring"))]
136        /// define a tls server cert config to be used for tls terminaton
137        /// by the IP service.
138        pub fn tls_server_config(mut self, cfg: Option<TlsConfig>) -> Self {
139            self.tls_server_config = cfg;
140            self
141        }
142    }
143}
144
145impl IpServiceBuilder<mode::Http> {
146    #[allow(unused_mut)]
147    #[inline]
148    /// build a tcp service ready to echo the client IP back
149    pub fn build(
150        mut self,
151        executor: Executor,
152    ) -> Result<impl Service<TcpStream, Response = (), Error = Infallible>, BoxError> {
153        #[cfg(all(feature = "rustls", not(feature = "boring")))]
154        let tls_cfg = self.tls_server_config.take();
155
156        #[cfg(feature = "boring")]
157        let tls_cfg: Option<TlsAcceptorData> = match self.tls_server_config.take() {
158            Some(cfg) => Some(cfg.try_into()?),
159            None => None,
160        };
161
162        #[cfg(any(feature = "rustls", feature = "boring"))]
163        {
164            let maybe_tls_acceptor_layer = tls_cfg.map(TlsAcceptorLayer::new);
165            self.build_http(executor, maybe_tls_acceptor_layer)
166        }
167
168        #[cfg(not(any(feature = "rustls", feature = "boring")))]
169        self.build_http(executor)
170    }
171}
172
173#[derive(Debug, Clone)]
174#[non_exhaustive]
175/// The inner http ip-service used by the [`IpServiceBuilder`].
176struct HttpIpService;
177
178impl Service<Request> for HttpIpService {
179    type Response = Response;
180    type Error = BoxError;
181
182    async fn serve(&self, req: Request) -> Result<Self::Response, Self::Error> {
183        let norm_req_path = req.uri().path().trim_matches('/');
184        if !norm_req_path.is_empty() {
185            tracing::debug!("unexpected request path '{norm_req_path}', redirect to root");
186            return Ok(Redirect::permanent("/").into_response());
187        }
188
189        let peer_ip = req
190            .extensions()
191            .get::<Forwarded>()
192            .and_then(|f| f.client_ip())
193            .or_else(|| {
194                req.extensions()
195                    .get::<SocketInfo>()
196                    .map(|s| s.peer_addr().ip())
197            });
198
199        Ok(match peer_ip {
200            Some(ip) => match HttpBodyContentFormat::derive_from_req(&req) {
201                HttpBodyContentFormat::Txt => ip.to_string().into_response(),
202                HttpBodyContentFormat::Html => format_html_page(ip).into_response(),
203                HttpBodyContentFormat::Json => Json(serde_json::json!({
204                    "ip": ip,
205                }))
206                .into_response(),
207            },
208            None => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
209        })
210    }
211}
212
213#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
214enum HttpBodyContentFormat {
215    #[default]
216    Txt,
217    Html,
218    Json,
219}
220
221impl HttpBodyContentFormat {
222    fn derive_from_req(req: &Request) -> Self {
223        let Some(accept) = req.headers().typed_get::<Accept>() else {
224            return Self::default();
225        };
226        accept
227            .iter()
228            .find_map(|qv| {
229                let r#type = qv.value.subtype();
230                if r#type == mime::JSON {
231                    Some(Self::Json)
232                } else if r#type == mime::HTML {
233                    Some(Self::Html)
234                } else if r#type == mime::TEXT {
235                    Some(Self::Txt)
236                } else {
237                    None
238                }
239            })
240            .unwrap_or_default()
241    }
242}
243
244#[derive(Debug, Clone)]
245#[non_exhaustive]
246/// The inner tcp echo-service used by the [`IpServiceBuilder`].
247struct TcpIpService;
248
249impl<Input> Service<Input> for TcpIpService
250where
251    Input: Stream + Unpin + ExtensionsRef,
252{
253    type Response = ();
254    type Error = BoxError;
255
256    async fn serve(&self, stream: Input) -> Result<Self::Response, Self::Error> {
257        tracing::info!("connection received");
258        let peer_ip = stream
259            .extensions()
260            .get::<Forwarded>()
261            .and_then(|f| f.client_ip())
262            .or_else(|| {
263                stream
264                    .extensions()
265                    .get::<SocketInfo>()
266                    .map(|s| s.peer_addr().ip())
267            });
268        let Some(peer_ip) = peer_ip else {
269            tracing::error!("missing peer information");
270            return Ok(());
271        };
272
273        let mut stream = std::pin::pin!(stream);
274
275        match peer_ip {
276            std::net::IpAddr::V4(ip) => {
277                if let Err(err) = stream.write_all(&ip.octets()).await {
278                    tracing::error!("error writing IPv4 of peer to peer: {}", err);
279                }
280            }
281            std::net::IpAddr::V6(ip) => {
282                if let Err(err) = stream.write_all(&ip.octets()).await {
283                    tracing::error!("error writing IPv6 of peer to peer: {}", err);
284                }
285            }
286        };
287
288        Ok(())
289    }
290}
291
292impl IpServiceBuilder<mode::Transport> {
293    #[allow(unused_mut)]
294    #[inline]
295    /// build a tcp service ready to echo client IP back
296    pub fn build(
297        mut self,
298    ) -> Result<impl Service<TcpStream, Response = (), Error = Infallible>, BoxError> {
299        #[cfg(all(feature = "rustls", not(feature = "boring")))]
300        let tls_cfg = self.tls_server_config.take();
301
302        #[cfg(feature = "boring")]
303        let tls_cfg: Option<TlsAcceptorData> = match self.tls_server_config.take() {
304            Some(cfg) => Some(cfg.try_into()?),
305            None => None,
306        };
307
308        #[cfg(any(feature = "rustls", feature = "boring"))]
309        {
310            let maybe_tls_acceptor_layer = tls_cfg.map(TlsAcceptorLayer::new);
311            self.build_tcp(maybe_tls_acceptor_layer)
312        }
313
314        #[cfg(not(any(feature = "rustls", feature = "boring")))]
315        self.build_tcp()
316    }
317}
318
319impl<M> IpServiceBuilder<M> {
320    fn build_tcp<S: Stream + ExtensionsMut + Unpin + Send + Sync + 'static>(
321        self,
322        #[cfg(any(feature = "rustls", feature = "boring"))] maybe_tls_accept_layer: Option<
323            TlsAcceptorLayer,
324        >,
325    ) -> Result<impl Service<S, Response = (), Error = Infallible>, BoxError> {
326        let tcp_forwarded_layer = match &self.forward {
327            None => None,
328            Some(ForwardKind::HaProxy) => Some(HaProxyLayer::default()),
329            Some(other) => {
330                return Err(OpaqueError::from_display(format!(
331                    "invalid forward kind for Transport mode: {other:?}"
332                ))
333                .into());
334            }
335        };
336
337        let tcp_service_builder = (
338            ConsumeErrLayer::trace(tracing::Level::DEBUG),
339            (self.concurrent_limit > 0)
340                .then(|| LimitLayer::new(ConcurrentPolicy::max(self.concurrent_limit))),
341            (!self.timeout.is_zero()).then(|| TimeoutLayer::new(self.timeout)),
342            tcp_forwarded_layer,
343            #[cfg(any(feature = "rustls", feature = "boring"))]
344            maybe_tls_accept_layer,
345        );
346
347        Ok(tcp_service_builder.into_layer(TcpIpService))
348    }
349
350    fn build_http<S: Stream + Unpin + Send + Sync + ExtensionsMut + 'static>(
351        self,
352        executor: Executor,
353        #[cfg(any(feature = "rustls", feature = "boring"))] maybe_tls_accept_layer: Option<
354            TlsAcceptorLayer,
355        >,
356    ) -> Result<impl Service<S, Response = (), Error = Infallible>, BoxError> {
357        let (tcp_forwarded_layer, http_forwarded_layer) = match &self.forward {
358            None => (None, None),
359            Some(ForwardKind::Forwarded) => {
360                (None, Some(Either7::A(GetForwardedHeaderLayer::forwarded())))
361            }
362            Some(ForwardKind::XForwardedFor) => (
363                None,
364                Some(Either7::B(GetForwardedHeaderLayer::x_forwarded_for())),
365            ),
366            Some(ForwardKind::XClientIp) => (
367                None,
368                Some(Either7::C(GetForwardedHeaderLayer::<XClientIp>::new())),
369            ),
370            Some(ForwardKind::ClientIp) => (
371                None,
372                Some(Either7::D(GetForwardedHeaderLayer::<ClientIp>::new())),
373            ),
374            Some(ForwardKind::XRealIp) => (
375                None,
376                Some(Either7::E(GetForwardedHeaderLayer::<XRealIp>::new())),
377            ),
378            Some(ForwardKind::CFConnectingIp) => (
379                None,
380                Some(Either7::F(GetForwardedHeaderLayer::<CFConnectingIp>::new())),
381            ),
382            Some(ForwardKind::TrueClientIp) => (
383                None,
384                Some(Either7::G(GetForwardedHeaderLayer::<TrueClientIp>::new())),
385            ),
386            Some(ForwardKind::HaProxy) => (Some(HaProxyLayer::default()), None),
387        };
388
389        #[cfg(any(feature = "rustls", feature = "boring"))]
390        let hsts_layer = maybe_tls_accept_layer.is_some().then(|| {
391            SetResponseHeaderLayer::if_not_present_typed(
392                StrictTransportSecurity::excluding_subdomains(Duration::from_secs(31536000)),
393            )
394        });
395
396        let tcp_service_builder = (
397            ConsumeErrLayer::trace(tracing::Level::DEBUG),
398            (self.concurrent_limit > 0)
399                .then(|| LimitLayer::new(ConcurrentPolicy::max(self.concurrent_limit))),
400            (!self.timeout.is_zero()).then(|| TimeoutLayer::new(self.timeout)),
401            tcp_forwarded_layer,
402            // Limit the body size to 1MB for requests
403            BodyLimitLayer::request_only(1024 * 1024),
404            #[cfg(any(feature = "rustls", feature = "boring"))]
405            maybe_tls_accept_layer,
406        );
407
408        let http_service = (
409            TraceLayer::new_for_http(),
410            AddRequiredResponseHeadersLayer::default(),
411            ConsumeErrLayer::default(),
412            #[cfg(any(feature = "rustls", feature = "boring"))]
413            hsts_layer,
414            http_forwarded_layer,
415        )
416            .into_layer(HttpIpService);
417
418        Ok(tcp_service_builder.into_layer(HttpServer::auto(executor).service(http_service)))
419    }
420}
421
422pub mod mode {
423    //! operation modes of the ip service
424
425    #[derive(Debug, Clone)]
426    #[non_exhaustive]
427    /// Default mode of the Ip service, echo'ng the info back over http
428    pub struct Http;
429
430    #[derive(Debug, Clone)]
431    #[non_exhaustive]
432    /// Alternative mode of the Ip service, echo'ng the ip info over tcp
433    pub struct Transport;
434}
435
436fn format_html_page(ip: IpAddr) -> Html<String> {
437    Html(format!(
438        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>"##,
439    ))
440}