first commit

This commit is contained in:
Mateusz Gruszczyński committed 2026-11-05 12:52:32 +01:00
commit 19dfc51360
85 files changed
+25504

No files matched your search

+215
View File
@@ -0,0 +1,215 @@
//! 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)
}
pub async fn handler(
State(app): State<Arc<App>>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
method: Method,
headers: HeaderMap,
body: Body,
) -> Response {
let s = app.settings();
let ip = client_ip(peer, &headers, &s).to_string();
let started = std::time::Instant::now();
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 != MOBILESYNC {
tracing::info!(ip = %ip, schema = %schema, "autodiscover: unsupported response schema");
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();
}
if let Some((l, p)) = &creds {
let key = app.pool.key(l, p);
if !app.auth.is_cached(&key) {
let mut sess = app.session(l, p);
match sess.authenticate().await {
Ok(AuthResult::Ok) => {
app.auth.success(&user_key);
app.auth.cache_ok(key, s.security.auth_cache_secs);
sess.close().await;
}
Ok(AuthResult::Denied) => {
app.metrics.auth_failure();
if s.security.log_auth_failures {
tracing::warn!(target: "active_push::auth", "authentication failed user={l} 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();
}
Err(e) => {
tracing::error!("autodiscover: backend unavailable: {e:#}");
return StatusCode::SERVICE_UNAVAILABLE.into_response();
}
}
}
}
if email.is_empty() || !email.contains('@') {
email = crate::backend::primary_email(&s, &login);
}
let url = if !s.autodiscover.server_url.is_empty() {
s.autodiscover.server_url.clone()
} 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)
);
tracing::info!(user = %login, ip = %ip, "autodiscover answered");
app.metrics.record(Event {
ts: now(),
user: login,
device_id: String::new(),
device_type: "Autodiscover".into(),
command: "Autodiscover".into(),
ip,
http: 200,
status: "ok".into(),
ms: started.elapsed().as_millis() as u64,
bytes_in: body.len() as u64,
bytes_out: xml.len() as u64,
items_out: 0,
items_in: 0,
});
xml_response(StatusCode::OK, xml)
}