first commit
This commit is contained in:
commit
19dfc51360
85 files changed
+25504
No files matched your search
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user