diff --git a/Cargo.toml b/Cargo.toml index 350f5f1..ac8e9d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" [dependencies] axum = "0.8.8" +tower = { version = "0.5", features = ["util"] } tokio = { version = "1.49.0", features = ["rt-multi-thread", "macros", "net", "signal", "time", "fs", "io-util"] } askama = "0.15.4" maxminddb = "0.27.3" diff --git a/src/handlers.rs b/src/handlers.rs index 15631fe..2be69dc 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -656,6 +656,45 @@ pub async fn health() -> StatusCode { StatusCode::OK } +fn extract_ipv4_client_ip(headers: &HeaderMap, dev_mode: bool) -> Result { + if let Some(ip) = headers + .get("X-IPv4-Client-IP") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.trim().parse().ok()) + { + return Ok(ip); + } + + if dev_mode { + return Ok(DEV_FALLBACK_IP); + } + + Err(AppError::MissingClientIp) +} + +pub async fn ipv4_ip_handler( + State(state): State, + headers: HeaderMap, +) -> Result { + let ip = extract_ipv4_client_ip(&headers, state.dev_mode)?; + let origin = format!("https://{}", state.site_domain); + Ok(( + [ + (header::ACCESS_CONTROL_ALLOW_ORIGIN, origin), + ( + header::ACCESS_CONTROL_ALLOW_METHODS, + "GET".to_owned(), + ), + ( + header::HeaderName::from_static("access-control-max-age"), + "86400".to_owned(), + ), + ], + format!("{ip}\n"), + ) + .into_response()) +} + pub async fn json_handler( State(state): State, headers: HeaderMap, @@ -915,3 +954,7 @@ pub async fn not_found(State(state): State, headers: HeaderMap) -> Res "The page you are looking for does not exist.", ) } + +pub async fn ipv4_not_found() -> StatusCode { + StatusCode::NOT_FOUND +} diff --git a/src/middleware.rs b/src/middleware.rs index 3bf5a14..8f91dbe 100644 --- a/src/middleware.rs +++ b/src/middleware.rs @@ -5,8 +5,29 @@ use axum::response::Response; pub async fn security_headers(request: Request, next: Next) -> Response { let mut response = next.run(request).await; - let headers = response.headers_mut(); + apply_common_security_headers(response.headers_mut()); + response.headers_mut().insert( + header::HeaderName::from_static("cross-origin-opener-policy"), + HeaderValue::from_static("same-origin"), + ); + response.headers_mut().insert( + header::HeaderName::from_static("cross-origin-resource-policy"), + HeaderValue::from_static("same-origin"), + ); + response +} +pub async fn ipv4_domain_security_headers(request: Request, next: Next) -> Response { + let mut response = next.run(request).await; + apply_common_security_headers(response.headers_mut()); + response.headers_mut().insert( + header::HeaderName::from_static("cross-origin-resource-policy"), + HeaderValue::from_static("cross-origin"), + ); + response +} + +fn apply_common_security_headers(headers: &mut axum::http::HeaderMap) { headers.insert( header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"), @@ -28,14 +49,4 @@ pub async fn security_headers(request: Request, next: Next) -> Response { header::HeaderName::from_static("permissions-policy"), HeaderValue::from_static("camera=(), microphone=(), geolocation=(), interest-cohort=()"), ); - headers.insert( - header::HeaderName::from_static("cross-origin-opener-policy"), - HeaderValue::from_static("same-origin"), - ); - headers.insert( - header::HeaderName::from_static("cross-origin-resource-policy"), - HeaderValue::from_static("same-origin"), - ); - - response } diff --git a/src/routes.rs b/src/routes.rs index 6d43331..bec1f2a 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -1,12 +1,16 @@ +use std::sync::Arc; + use axum::Router; use axum::middleware; +use axum::response::IntoResponse; use axum::routing::get; +use tower::ServiceExt as _; use crate::handlers::{self, AppState}; -use crate::middleware::security_headers; +use crate::middleware::{ipv4_domain_security_headers, security_headers}; use crate::static_assets; -pub fn build_router(state: AppState) -> Router { +fn build_main_router(state: AppState) -> Router { Router::new() .route("/", get(handlers::root_redirect)) .route("/favicon.ico", get(handlers::favicon)) @@ -32,3 +36,45 @@ pub fn build_router(state: AppState) -> Router { .layer(middleware::from_fn(security_headers)) .with_state(state) } + +fn build_ipv4_router(state: AppState) -> Router { + Router::new() + .route("/", get(handlers::ipv4_ip_handler)) + .route("/health", get(handlers::health)) + .fallback(handlers::ipv4_not_found) + .layer(middleware::from_fn(ipv4_domain_security_headers)) + .with_state(state) +} + +pub fn build_router(state: AppState) -> Router { + let ipv4_domain: Arc = state.ipv4_domain.clone(); + let main_router = build_main_router(state.clone()); + let ipv4_router = build_ipv4_router(state); + + let svc = tower::service_fn(move |req: axum::http::Request| { + let ipv4_domain = ipv4_domain.clone(); + let main_router = main_router.clone(); + let ipv4_router = ipv4_router.clone(); + async move { + let is_ipv4_domain = !ipv4_domain.is_empty() + && req + .headers() + .get("host") + .and_then(|v| v.to_str().ok()) + .is_some_and(|host| { + let host = host.split(':').next().unwrap_or(host); + host.eq_ignore_ascii_case(&ipv4_domain) + }); + + // Router<()> is an infallible service; unwrap is safe. + let response = if is_ipv4_domain { + ipv4_router.oneshot(req).await.into_response() + } else { + main_router.oneshot(req).await.into_response() + }; + Ok::<_, std::convert::Infallible>(response) + } + }); + + Router::new().fallback_service(svc) +}