#![expect(
clippy::allow_attributes,
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"
)]
use crate::{
Layer, Service,
cli::ForwardKind,
combinators::Either,
combinators::Either7,
error::{BoxError, BoxErrorExt, ErrorExt as _},
extensions::ExtensionsRef,
http::BodyLimitLayer,
http::{
Request, Response, StatusCode,
headers::exotic::XClacksOverhead,
headers::forwarded::{CFConnectingIp, ClientIp, TrueClientIp, XClientIp, XRealIp},
headers::{Accept, HeaderMapExt},
layer::{
forwarded::GetForwardedHeaderLayer, required_header::AddRequiredResponseHeadersLayer,
set_header::SetResponseHeaderLayer, trace::TraceLayer,
},
mime,
server::HttpServer,
service::web::response::{Css, IntoResponse, Json, Redirect, Script},
},
io::Io,
layer::limit::policy::UnlimitedPolicy,
layer::{ConsumeErrLayer, LimitLayer, TimeoutLayer, limit::policy::ConcurrentPolicy},
net::address::ip::geo::{GeoLocation, IpGeoDb, IpGeoInfo},
net::forwarded::Forwarded,
net::stream::SocketInfo,
proxy::haproxy::server::HaProxyLayer,
rt::Executor,
tcp::TcpStream,
telemetry::tracing,
utils::octets::mib,
};
use std::{convert::Infallible, marker::PhantomData, net::IpAddr, sync::Arc, time::Duration};
use tokio::io::AsyncWriteExt;
core::cfg_select! {
feature = "boring" => {
use crate::tls::boring::server::TlsAcceptorLayer;
}
feature = "rustls" => {
use crate::tls::rustls::server::TlsAcceptorLayer;
}
_ => {}
}
#[cfg(any(feature = "rustls", feature = "boring"))]
use crate::{http::headers::StrictTransportSecurity, tls::server::TlsServerConfig};
#[derive(Debug, Clone)]
pub struct IpServiceBuilder<M> {
#[cfg(any(feature = "rustls", feature = "boring"))]
tls_server_config: Option<TlsServerConfig>,
concurrent_limit: usize,
timeout: Duration,
forward: Option<ForwardKind>,
geo_db: Option<Arc<IpGeoDb>>,
_mode: PhantomData<fn(M)>,
}
impl IpServiceBuilder<mode::Http> {
#[must_use]
pub fn http() -> Self {
Self {
#[cfg(any(feature = "rustls", feature = "boring"))]
tls_server_config: None,
concurrent_limit: 0,
timeout: Duration::ZERO,
forward: None,
geo_db: None,
_mode: PhantomData,
}
}
}
impl IpServiceBuilder<mode::Transport> {
#[must_use]
pub fn tcp() -> Self {
Self {
#[cfg(any(feature = "rustls", feature = "boring"))]
tls_server_config: None,
concurrent_limit: 0,
timeout: Duration::ZERO,
forward: None,
geo_db: None,
_mode: PhantomData,
}
}
}
impl<M> IpServiceBuilder<M> {
crate::utils::macros::generate_set_and_with! {
#[must_use]
pub fn concurrent(mut self, limit: usize) -> Self {
self.concurrent_limit = limit;
self
}
}
crate::utils::macros::generate_set_and_with! {
#[must_use]
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
}
crate::utils::macros::generate_set_and_with! {
#[must_use]
pub fn forward(mut self, maybe_kind: Option<ForwardKind>) -> Self {
self.forward = maybe_kind;
self
}
}
crate::utils::macros::generate_set_and_with! {
#[must_use]
pub fn geo_db(mut self, db: Option<Arc<IpGeoDb>>) -> Self {
self.geo_db = db;
self
}
}
crate::utils::macros::generate_set_and_with! {
#[cfg(any(feature = "rustls", feature = "boring"))]
pub fn tls_server_config(mut self, cfg: Option<TlsServerConfig>) -> Self {
self.tls_server_config = cfg;
self
}
}
}
impl IpServiceBuilder<mode::Http> {
#[allow(unused_mut)]
#[inline]
pub fn build(
mut self,
executor: Executor,
) -> Result<impl Service<TcpStream, Output = (), Error = Infallible>, BoxError> {
#[cfg(any(feature = "rustls", feature = "boring"))]
{
let maybe_tls_acceptor_layer = self.tls_server_config.take().map(TlsAcceptorLayer::new);
self.build_http(executor, maybe_tls_acceptor_layer)
}
#[cfg(not(any(feature = "rustls", feature = "boring")))]
self.build_http(executor)
}
}
#[derive(Debug, Clone)]
struct HttpIpService {
geo_db: Option<Arc<IpGeoDb>>,
}
impl Service<Request> for HttpIpService {
type Output = Response;
type Error = Infallible;
async fn serve(&self, req: Request) -> Result<Self::Output, Self::Error> {
let peer_ip = req
.extensions()
.get_ref::<Forwarded>()
.and_then(|f| f.client_ip())
.or_else(|| {
req.extensions()
.get_ref::<SocketInfo>()
.map(|s| s.peer_addr().ip_addr)
});
Ok(match peer_ip {
Some(ip) => match HttpBodyContentFormat::derive_from_req(&req) {
HttpBodyContentFormat::Txt => ip.to_string().into_response(),
HttpBodyContentFormat::Html => {
let geo = self.geo_db.as_ref().and_then(|db| db.resolve(ip));
let attributions: Vec<_> = self
.geo_db
.as_ref()
.map(|db| db.attributions().collect())
.unwrap_or_default();
render_html_page(ip, geo.as_ref(), &attributions).into_response()
}
HttpBodyContentFormat::Json => {
let geo = self.geo_db.as_ref().and_then(|db| db.resolve(ip));
let mut body = serde_json::json!({ "ip": ip });
if let Some(info) = geo {
body["geo"] = serde_json::to_value(&info).unwrap_or_default();
}
Json(body).into_response()
}
},
None => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
})
}
}
const IP_STYLE_CSS: &str = include_str!("ip.css");
const IP_SCRIPT_JS: &str = include_str!("ip.js");
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
enum HttpBodyContentFormat {
#[default]
Txt,
Html,
Json,
}
impl HttpBodyContentFormat {
fn derive_from_req(req: &Request) -> Self {
let Some(accept) = req.headers().typed_get::<Accept>() else {
return Self::default();
};
let mut entries: Vec<_> = accept.0.iter().collect();
entries.sort_by_key(|qv| std::cmp::Reverse(qv.quality));
entries
.into_iter()
.find_map(|qv| {
let r#type = qv.value.subtype();
if r#type == mime::JSON {
Some(Self::Json)
} else if r#type == mime::HTML {
Some(Self::Html)
} else if r#type == mime::TEXT {
Some(Self::Txt)
} else {
None
}
})
.unwrap_or_default()
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
struct TcpIpService;
impl<Input> Service<Input> for TcpIpService
where
Input: Io + Unpin + ExtensionsRef,
{
type Output = ();
type Error = BoxError;
async fn serve(&self, stream: Input) -> Result<Self::Output, Self::Error> {
tracing::info!("connection received");
let peer_ip = stream
.extensions()
.get_ref::<Forwarded>()
.and_then(|f| f.client_ip())
.or_else(|| {
stream
.extensions()
.get_ref::<SocketInfo>()
.map(|s| s.peer_addr().ip_addr)
});
let Some(peer_ip) = peer_ip else {
tracing::error!("missing peer information");
return Ok(());
};
let mut stream = std::pin::pin!(stream);
match peer_ip {
std::net::IpAddr::V4(ip) => {
if let Err(err) = stream.write_all(&ip.octets()).await {
tracing::error!("error writing IPv4 of peer to peer: {}", err);
}
}
std::net::IpAddr::V6(ip) => {
if let Err(err) = stream.write_all(&ip.octets()).await {
tracing::error!("error writing IPv6 of peer to peer: {}", err);
}
}
};
Ok(())
}
}
impl IpServiceBuilder<mode::Transport> {
#[allow(unused_mut)]
#[inline]
pub fn build(
mut self,
) -> Result<impl Service<TcpStream, Output = (), Error = Infallible>, BoxError> {
#[cfg(any(feature = "rustls", feature = "boring"))]
{
let maybe_tls_acceptor_layer = self.tls_server_config.take().map(TlsAcceptorLayer::new);
self.build_tcp(maybe_tls_acceptor_layer)
}
#[cfg(not(any(feature = "rustls", feature = "boring")))]
self.build_tcp()
}
}
impl<M> IpServiceBuilder<M> {
fn build_tcp<S: Io + ExtensionsRef + Unpin + Sync>(
self,
#[cfg(any(feature = "rustls", feature = "boring"))] maybe_tls_accept_layer: Option<
TlsAcceptorLayer,
>,
) -> Result<impl Service<S, Output = (), Error = Infallible>, BoxError> {
let tcp_forwarded_layer = match &self.forward {
None => None,
Some(ForwardKind::HaProxy) => Some(HaProxyLayer::default()),
Some(other) => {
return Err(
BoxError::from_static_str("invalid forward kind for Transport mode")
.with_context_debug_field("kind", || other.clone()),
);
}
};
let tcp_service_builder = (
ConsumeErrLayer::trace_as(tracing::Level::DEBUG),
LimitLayer::new(if self.concurrent_limit > 0 {
Either::A(ConcurrentPolicy::max(self.concurrent_limit))
} else {
Either::B(UnlimitedPolicy::new())
}),
if !self.timeout.is_zero() {
TimeoutLayer::new(self.timeout)
} else {
TimeoutLayer::never()
},
tcp_forwarded_layer,
#[cfg(any(feature = "rustls", feature = "boring"))]
maybe_tls_accept_layer,
);
Ok(tcp_service_builder.into_layer(TcpIpService))
}
fn build_http<S: Io + Unpin + Sync + ExtensionsRef>(
self,
executor: Executor,
#[cfg(any(feature = "rustls", feature = "boring"))] maybe_tls_accept_layer: Option<
TlsAcceptorLayer,
>,
) -> Result<impl Service<S, Output = (), Error = Infallible>, BoxError> {
let (tcp_forwarded_layer, http_forwarded_layer) = match &self.forward {
None => (None, None),
Some(ForwardKind::Forwarded) => {
(None, Some(Either7::A(GetForwardedHeaderLayer::forwarded())))
}
Some(ForwardKind::XForwardedFor) => (
None,
Some(Either7::B(GetForwardedHeaderLayer::x_forwarded_for())),
),
Some(ForwardKind::XClientIp) => (
None,
Some(Either7::C(GetForwardedHeaderLayer::<XClientIp>::new())),
),
Some(ForwardKind::ClientIp) => (
None,
Some(Either7::D(GetForwardedHeaderLayer::<ClientIp>::new())),
),
Some(ForwardKind::XRealIp) => (
None,
Some(Either7::E(GetForwardedHeaderLayer::<XRealIp>::new())),
),
Some(ForwardKind::CFConnectingIp) => (
None,
Some(Either7::F(GetForwardedHeaderLayer::<CFConnectingIp>::new())),
),
Some(ForwardKind::TrueClientIp) => (
None,
Some(Either7::G(GetForwardedHeaderLayer::<TrueClientIp>::new())),
),
Some(ForwardKind::HaProxy) => (Some(HaProxyLayer::default()), None),
};
#[cfg(any(feature = "rustls", feature = "boring"))]
let hsts_layer = maybe_tls_accept_layer.is_some().then(|| {
SetResponseHeaderLayer::if_not_present_typed(
StrictTransportSecurity::excluding_subdomains_for_max_seconds(31536000),
)
});
let tcp_service_builder = (
ConsumeErrLayer::trace_as(tracing::Level::DEBUG),
(self.concurrent_limit > 0)
.then(|| LimitLayer::new(ConcurrentPolicy::max(self.concurrent_limit))),
(!self.timeout.is_zero()).then(|| TimeoutLayer::new(self.timeout)),
tcp_forwarded_layer,
BodyLimitLayer::request_only(mib(1)),
#[cfg(any(feature = "rustls", feature = "boring"))]
maybe_tls_accept_layer,
);
let (csp_layer, nosniff_layer, referrer_layer, frame_layer) =
crate::cli::service::http_security::defence_in_depth_layer(
crate::cli::service::http_security::rama_html_csp(),
);
let geo_attribution = self.geo_db.as_ref().and_then(|db| {
let notices: Vec<_> = db.attributions().collect();
(!notices.is_empty()).then(|| crate::cli::service::geo::geo_attribution_layer(notices))
});
let router = crate::http::service::web::Router::new()
.with_get(
"/",
HttpIpService {
geo_db: self.geo_db,
},
)
.with_get("/style/ip.css", Css(IP_STYLE_CSS))
.with_get("/script/ip.js", Script(IP_SCRIPT_JS))
.with_not_found(async || Redirect::permanent("/"));
let http_service = (
TraceLayer::new_for_http(),
SetResponseHeaderLayer::<XClacksOverhead>::if_not_present_default_typed(),
AddRequiredResponseHeadersLayer::default(),
geo_attribution,
csp_layer,
nosniff_layer,
referrer_layer,
frame_layer,
ConsumeErrLayer::default(),
#[cfg(any(feature = "rustls", feature = "boring"))]
hsts_layer,
http_forwarded_layer,
)
.into_layer(router);
let http_service = Arc::new(http_service);
Ok(tcp_service_builder.into_layer(HttpServer::auto(executor).service(http_service)))
}
}
pub mod mode {
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Http;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Transport;
}
fn render_html_page(
ip: IpAddr,
geo: Option<&IpGeoInfo>,
attributions: &[&str],
) -> impl crate::http::protocols::html::IntoHtml + IntoResponse {
use crate::http::protocols::html::*;
let geo_comment =
crate::cli::service::geo::geo_attribution_html_comment(attributions).map(PreEscaped);
let geo_panel = geo.map(|info| {
let rows = |loc: &GeoLocation| {
crate::cli::service::geo::geo_location_rows(loc)
.into_iter()
.map(|(k, v)| div!(class = "georow", div!(class = "muted", k), div!(code!(v))))
.collect::<Vec<_>>()
};
let card = |label: String, loc: &GeoLocation| {
div!(
class = "panel geo-card",
div!(class = "muted geo-source", label),
rows(loc),
)
};
let mut cards = vec![card("merged".to_owned(), &info.location)];
cards.extend(
info.by_source
.iter()
.map(|src| card(src.label.to_string(), &src.location)),
);
div!(
class = "geo-section",
role = "region",
"aria-label" = "geo panel",
div!(class = "muted geo-title", "Geolocation"),
div!(class = "geo-grid", cards),
)
});
html!(
lang = "en",
head!(
meta!(charset = "utf-8"),
meta!(
name = "viewport",
content = "width=device-width,initial-scale=1"
),
link!(
rel = "icon",
href = PreEscaped(
"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"),
link!(
rel = "stylesheet",
r#type = "text/css",
href = "/style/ip.css"
),
),
body!(
geo_comment,
div!(
class = "card",
div!(
class = "logo",
div!("π¦"),
div!(a!(href = "https://ramaproxy.org", "γ©γ")),
),
div!(
class = "panel",
role = "region",
"aria-label" = "ip panel",
div!(class = "muted", "Your public ip"),
div!(id = "ip", class = "ip", code!(ip.to_string())),
div!(
class = "controls",
button!(
id = "copyBtn",
class = "primary",
title = "Copy ip to clipboard",
"π Copy IP",
),
),
),
geo_panel,
script!(src = "/script/ip.js"),
)
),
)
}
#[cfg(test)]
mod render_html_page_tests {
use super::*;
use crate::http::protocols::html::IntoHtml as _;
use std::net::Ipv4Addr;
#[test]
fn render_html_page_embeds_ip_safely() {
let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
let out = render_html_page(ip, None, &[]).into_string();
assert!(out.starts_with("<!DOCTYPE html><html lang=\"en\">"));
assert!(out.contains("<title>Rama IP</title>"));
assert!(out.contains(r#"<div id="ip" class="ip"><code>127.0.0.1</code></div>"#));
assert!(out.contains(r#"id="copyBtn""#));
}
#[test]
fn render_html_page_emits_aria_label() {
let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 0, 1));
let out = render_html_page(ip, None, &[]).into_string();
assert!(out.contains(r#"aria-label="ip panel""#));
}
#[test]
fn render_html_page_uses_external_assets() {
let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
let out = render_html_page(ip, None, &[]).into_string();
assert!(
!out.contains("<style>") && !out.contains("<style "),
"IP page must not embed inline <style>; CSP blocks it"
);
assert!(
!out.contains("<script>"),
"IP page must not embed inline <script>; CSP blocks it"
);
assert!(
out.contains(r#"<link rel="stylesheet" type="text/css" href="/style/ip.css">"#),
"IP page must link to /style/ip.css",
);
assert!(
out.contains(r#"<script src="/script/ip.js">"#),
"IP page must source /script/ip.js",
);
}
#[test]
fn render_html_page_renders_geo_panel() {
use crate::geo::Country;
use crate::net::address::ip::geo::{GeoLocation, IpGeoInfo, IpGeoSourceResult};
let ip = IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4));
let loc = GeoLocation {
country: Some(Country::Belgium),
..Default::default()
};
let info = IpGeoInfo {
ip,
location: loc.clone(),
by_source: vec![IpGeoSourceResult {
label: "geolite2".into(),
location: loc,
}],
};
let notices = ["This product includes GeoLite2 data created by MaxMind"];
let out = render_html_page(ip, Some(&info), ¬ices).into_string();
assert!(out.contains("Geolocation"), "geo panel title missing");
assert!(out.contains("Belgium"), "resolved country missing");
assert!(out.contains("geolite2"), "per-source label missing");
assert!(
out.contains("<!-- This product includes GeoLite2"),
"attribution comment missing"
);
let plain = render_html_page(ip, None, &[]).into_string();
assert!(!plain.contains("Geolocation"));
assert!(!plain.contains("<!--"));
}
}