//! 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)
}