381 lines
13 KiB
Rust
381 lines
13 KiB
Rust
//! 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#"<?xml version="1.0" encoding="utf-8"?>
|
|
<Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006">
|
|
<Response>
|
|
<Error Time="{}" Id="1">
|
|
<ErrorCode>{code}</ErrorCode>
|
|
<Message>{}</Message>
|
|
<DebugData />
|
|
</Error>
|
|
</Response>
|
|
</Autodiscover>"#,
|
|
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<Arc<App>>,
|
|
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
|
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::<String>()
|
|
};
|
|
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::<u64>().unwrap_or(0);
|
|
// request headers for the trace (never credentials or cookies)
|
|
let hdr_dump: Vec<String> = 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::<String>()
|
|
)
|
|
})
|
|
.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::<String>())
|
|
})
|
|
.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("<ErrorCode>") => "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<App>,
|
|
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<String> = 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<String> = 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#"<?xml version="1.0" encoding="utf-8"?>
|
|
<Autodiscover xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns="http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006">
|
|
<Response xmlns="{MOBILESYNC}">
|
|
<Culture>en:en</Culture>
|
|
<User>
|
|
<DisplayName>{}</DisplayName>
|
|
<EMailAddress>{}</EMailAddress>
|
|
</User>
|
|
<Action>
|
|
<Settings>
|
|
<Server>
|
|
<Type>MobileSync</Type>
|
|
<Url>{}</Url>
|
|
<Name>{}</Name>
|
|
</Server>
|
|
</Settings>
|
|
</Action>
|
|
</Response>
|
|
</Autodiscover>"#,
|
|
xml_escape(&display),
|
|
xml_escape(&email),
|
|
xml_escape(&url),
|
|
xml_escape(&url)
|
|
);
|
|
xml_response(StatusCode::OK, xml)
|
|
}
|