//! Autodiscover for mobile devices (MobileSync schema) - replaces z-push/autodiscover. use std::net::SocketAddr; use std::sync::Arc; use axum::body::Body; use axum::extract::{ConnectInfo, State}; use axum::http::{HeaderMap, HeaderValue, Method, StatusCode}; use axum::response::{IntoResponse, Response}; use http_body_util::BodyExt; use crate::app::App; use crate::backend::AuthResult; use crate::eas::{basic_auth, client_ip, normalize_login, user_allowed}; use crate::metrics::Event; use crate::util::{now, xml_escape}; use crate::xmltree; const MOBILESYNC: &str = "http://schemas.microsoft.com/exchange/autodiscover/mobilesync/responseschema/2006"; fn xml_response(status: StatusCode, body: String) -> Response { let mut r = (status, body).into_response(); r.headers_mut().insert( "Content-Type", HeaderValue::from_static("text/xml; charset=utf-8"), ); r } fn error_xml(code: u32, msg: &str) -> String { format!( r#" {code} {} "#, chrono::Utc::now().format("%H:%M:%S%.6f"), xml_escape(msg) ) } fn unauthorized() -> Response { let mut r = (StatusCode::UNAUTHORIZED, "Access denied").into_response(); r.headers_mut().insert( "WWW-Authenticate", HeaderValue::from_static("Basic realm=\"ActiveSync\""), ); r } fn safe_host(h: &str) -> Option<&str> { (!h.is_empty() && h.len() < 256 && h.chars() .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '[' | ']'))) .then_some(h) } /// Every attempt is logged and shown in the panel (Live), including the unauthenticated /// first request iOS sends and failures - this is what makes "my phone still asks for the /// server" debuggable. pub async fn handler( State(app): State>, ConnectInfo(peer): ConnectInfo, method: Method, uri: axum::http::Uri, headers: HeaderMap, body: Body, ) -> Response { let s = app.settings(); let started = std::time::Instant::now(); let ip = client_ip(peer, &headers, &s).to_string(); let hdr = |n: &str| { headers .get(n) .and_then(|v| v.to_str().ok()) .unwrap_or("") .chars() .filter(|c| !c.is_control()) .take(120) .collect::() }; let host = if hdr("x-forwarded-host").is_empty() { hdr("host") } else { hdr("x-forwarded-host") }; let ua = hdr("user-agent"); let user = basic_auth(&headers, &s) .map(|(l, _)| l) .unwrap_or_else(|| "-".into()); let size = hdr("content-length").parse::().unwrap_or(0); // request headers for the trace (never credentials or cookies) let hdr_dump: Vec = headers .iter() .filter(|(k, _)| { !matches!( k.as_str(), "authorization" | "cookie" | "proxy-authorization" ) }) .map(|(k, v)| { format!( "{}: {}", k.as_str(), String::from_utf8_lossy(v.as_bytes()) .chars() .filter(|c| !c.is_control()) .take(200) .collect::() ) }) .collect(); let typed_user = headers .get("authorization") .and_then(|v| v.to_str().ok()) .and_then(|v| v.split_once(' ')) .and_then(|(_, d)| { use base64::Engine; base64::engine::general_purpose::STANDARD .decode(d.trim()) .ok() }) .and_then(|b| String::from_utf8(b).ok()) .and_then(|d| { d.split_once(':') .map(|(u, _)| u.chars().take(120).collect::()) }) .unwrap_or_default(); let body_bytes = match http_body_util::Limited::new(body, 64 * 1024) .collect() .await { Ok(b) => b.to_bytes(), Err(_) => return StatusCode::PAYLOAD_TOO_LARGE.into_response(), }; let resp = inner( app.clone(), peer, method.clone(), headers, Body::from(body_bytes.clone()), ) .await; let (mut parts, rbody) = resp.into_parts(); let rbytes = rbody .collect() .await .map(|b| b.to_bytes()) .unwrap_or_default(); parts.headers.remove("content-length"); let mut resp = Response::from_parts(parts, Body::from(rbytes.clone())); resp.headers_mut() .insert("X-ActivePush", HeaderValue::from_static("autodiscover")); let code = resp.status().as_u16(); let status = match code { 200 if String::from_utf8_lossy(&rbytes).contains("") => "invalid request", 200 => "ok", 401 => "auth required", 429 => "locked out", 503 => "backend unavailable", _ => "error", }; tracing::info!(target: "active_push::autodiscover", ip = %ip, host = %host, path = %uri.path(), method = %method, http = code, user = %user, ua = %ua, "autodiscover {status}"); let clip = |b: &[u8]| -> String { String::from_utf8_lossy(&b[..b.len().min(3000)]) .chars() .filter(|c| !c.is_control() || *c == '\n') .collect() }; app.ad_trace_push(serde_json::json!({ "ts": now(), "ip": ip, "host": host, "method": method.as_str(), "path": uri.path(), "typed_user": typed_user, "http": code, "ms": started.elapsed().as_millis() as u64, "request_headers": hdr_dump, "request_body": clip(&body_bytes), "response_body": clip(&rbytes), })); app.metrics.record(Event { ts: now(), user, device_id: String::new(), device_type: "Autodiscover".into(), command: "Autodiscover".into(), ip, http: code, status: format!("{status} ({host})"), ms: started.elapsed().as_millis() as u64, bytes_in: size, bytes_out: 0, items_out: 0, items_in: 0, }); resp } async fn inner( app: Arc, peer: SocketAddr, method: Method, headers: HeaderMap, body: Body, ) -> Response { let s = app.settings(); let ip = client_ip(peer, &headers, &s).to_string(); app.metrics .c .autodiscover .fetch_add(1, std::sync::atomic::Ordering::Relaxed); if !s.autodiscover.enabled { return StatusCode::NOT_FOUND.into_response(); } if method != Method::POST && method != Method::GET { return StatusCode::METHOD_NOT_ALLOWED.into_response(); } let body = match http_body_util::Limited::new(body, 64 * 1024) .collect() .await { Ok(b) => b.to_bytes(), Err(_) => return StatusCode::PAYLOAD_TOO_LARGE.into_response(), }; // requested address + schema let (mut email, schema) = if body.is_empty() { (String::new(), MOBILESYNC.to_string()) } else { match xmltree::parse(&body) { Ok(x) => ( x.text_of("EMailAddress").unwrap_or_default(), x.text_of("AcceptableResponseSchema").unwrap_or_default(), ), Err(_) => return xml_response(StatusCode::OK, error_xml(600, "Invalid Request")), } }; if !schema.is_empty() && schema != MOBILESYNC { tracing::info!(ip = %ip, schema = %schema, "autodiscover: unsupported response schema (only MobileSync is served)"); return xml_response(StatusCode::OK, error_xml(600, "Invalid Request")); } // only the configured domains (when a list is set) if !s.autodiscover.domains.is_empty() { if let Some((_, dom)) = email.rsplit_once('@') { let dom = crate::autodiag::normalize_domain(dom); if !s.autodiscover.serves(&dom) { tracing::info!(ip = %ip, domain = %dom, "autodiscover: domain not in the configured list"); return xml_response(StatusCode::OK, error_xml(600, "Invalid Request")); } } } // authentication let creds = basic_auth(&headers, &s); let login = match (&creds, s.autodiscover.require_auth) { (None, true) => return unauthorized(), (Some((l, _)), _) => l.clone(), (None, false) => match normalize_login(&email, &s) { Some(l) => l, None => return xml_response(StatusCode::OK, error_xml(600, "Invalid Request")), }, }; let ip_key = format!("ip:{ip}"); let user_key = format!("user:{login}"); if app.auth.locked_until(&ip_key).is_some() || app.auth.locked_until(&user_key).is_some() { return (StatusCode::TOO_MANY_REQUESTS, "Too many failed logins").into_response(); } if !user_allowed(&login, &s) { return unauthorized(); } // iOS sends the same password twice: once with "user@domain" and once with the bare "user", // and treats a refusal of either as a failed Autodiscover. When the bare name is the local // part of the requested address, try the full address first (the form most IMAP servers with // several domains know), then the name as given. let mut login = login; if let Some((given, pass)) = &creds { let mut candidates: Vec = Vec::new(); if !given.contains('@') { if let Some((local, _)) = email.rsplit_once('@') { if local.eq_ignore_ascii_case(given) { if let Some(full) = normalize_login(&email, &s) { if user_allowed(&full, &s) { candidates.push(full); } } } } } candidates.push(given.clone()); let mut accepted: Option = candidates .iter() .find(|c| app.auth.is_cached(&app.pool.key(c, pass))) .cloned(); if accepted.is_none() { for cand in &candidates { let mut sess = app.session(cand, pass); match sess.authenticate().await { Ok(AuthResult::Ok) => { app.auth.success(&user_key); app.auth .cache_ok(app.pool.key(cand, pass), s.security.auth_cache_secs); sess.close().await; accepted = Some(cand.clone()); break; } Ok(AuthResult::Denied) => {} Err(e) => { tracing::error!("autodiscover: backend unavailable: {e:#}"); return StatusCode::SERVICE_UNAVAILABLE.into_response(); } } } } match accepted { Some(l) => login = l, None => { app.metrics.auth_failure(); if s.security.log_auth_failures { tracing::warn!(target: "active_push::auth", "authentication failed user={given} ip={ip} service=autodiscover"); } let (m, w, lo) = ( s.security.auth_fail_max, s.security.auth_fail_window_secs, s.security.lockout_secs, ); app.auth.fail(&ip_key, m * 3, w, lo); app.auth.fail(&user_key, m, w, lo); return unauthorized(); } } } if email.is_empty() || !email.contains('@') { email = crate::backend::primary_email(&s, &login); } let configured = email .rsplit_once('@') .map(|(_, d)| s.autodiscover.url_for(d)) .unwrap_or_else(|| s.autodiscover.server_url.trim().to_string()); let url = if !configured.is_empty() { configured } else { let host = headers .get("x-forwarded-host") .or_else(|| headers.get("host")) .and_then(|h| h.to_str().ok()) .and_then(safe_host) .unwrap_or("localhost"); format!("https://{host}/Microsoft-Server-ActiveSync") }; let display = email.clone(); let xml = format!( r#" en:en {} {} MobileSync {} {} "#, xml_escape(&display), xml_escape(&email), xml_escape(&url), xml_escape(&url) ); xml_response(StatusCode::OK, xml) }