diff --git a/.gitignore b/.gitignore index dfc87ba..8ae292e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,3 @@ dist/ *.db *.db-wal *.db-shm -/*.* diff --git a/CHANGELOG.md b/CHANGELOG.md index b728700..1a2f4f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## 0.1.8 - 2026-11-08 + +- Port 8081 landing page reduced to the flat application logo centred on the page (no gradients, shadows, text + or status indicator). + +## 0.1.7 - 2026-11-08 + +- Port 8081: `/` serves a small landing page with the application logo (self-contained, no scripts, CSP pinned by + hash, light and dark theme) instead of plain text; `/favicon.svg` added. `/healthz` and every other path unchanged. + +## 0.1.6 - 2026-11-08 + +- Autodiscover: the panel page lists the last 30 requests as received (headers without credentials, login as typed, + request and response bodies) to debug phones that still ask for the server. A 200 response carrying an + Autodiscover error (e.g. unsupported schema) is labelled "invalid request" in Live instead of "ok". + +## 0.1.5 - 2026-11-08 + +- Autodiscover: iOS sends the same password twice, with `user@domain` and with the bare `user`; IMAP servers that + only know the full address refused the second attempt and the phone treated the whole Autodiscover as failed + (manual "Server" field). When the bare name is the local part of the requested address, the full address is now + tried first, then the name as given. + ## 0.1.4 - 2026-11-07 - Autodiscover: several domains, each optionally with its own ActiveSync URL - one line per domain, e.g. diff --git a/Cargo.lock b/Cargo.lock index 0c38400..06ce556 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "active-push" -version = "0.1.4" +version = "0.1.8" dependencies = [ "anyhow", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index 49874cd..b330768 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "active-push" -version = "0.1.4" +version = "0.1.8" edition = "2021" rust-version = "1.85" description = "ActivePush - Exchange ActiveSync server (Z-Push rewrite) with built-in administration panel" diff --git a/docs/images/landing.png b/docs/images/landing.png new file mode 100644 index 0000000..bb61984 Binary files /dev/null and b/docs/images/landing.png differ diff --git a/src/admin.rs b/src/admin.rs index 36960de..2f7499d 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -220,6 +220,7 @@ pub fn router(app: Arc) -> Router { .route("/api/settings", get(get_settings).put(put_settings)) .route("/api/test/{what}", post(test_backend)) .route("/api/autodiscover/check", post(autodiscover_check)) + .route("/api/autodiscover/trace", get(autodiscover_trace)) .route("/api/logs", get(logs)) .route("/api/audit", get(audit)) .route("/api/lockouts", get(lockouts)) @@ -1003,6 +1004,11 @@ struct AdCheckReq { domain: String, } +/// Last Autodiscover requests as received (headers without credentials, bodies). +async fn autodiscover_trace(State(app): State>) -> Response { + Json(app.ad_trace_list()).into_response() +} + /// DNS + endpoint diagnostics for one of the configured Autodiscover domains. async fn autodiscover_check(State(app): State>, Json(req): Json) -> Response { let s = app.settings(); diff --git a/src/app.rs b/src/app.rs index 3b67193..1e804fd 100644 --- a/src/app.rs +++ b/src/app.rs @@ -45,9 +45,29 @@ pub struct App { /// Collection ids a device still uses but that do not exist here (e.g. left over from /// Z-Push). They are sent as FolderHierarchy Delete on the next FolderSync. stale: Mutex>>, + /// Last Autodiscover requests with headers (no secrets) and bodies, for the panel. + ad_trace: Mutex>, } impl App { + pub fn ad_trace_push(&self, v: serde_json::Value) { + let mut t = self.ad_trace.lock().unwrap(); + if t.len() >= 30 { + t.pop_front(); + } + t.push_back(v); + } + + pub fn ad_trace_list(&self) -> Vec { + self.ad_trace + .lock() + .unwrap() + .iter() + .rev() + .cloned() + .collect() + } + pub async fn new(boot: Boot, logs: Arc) -> anyhow::Result> { let db = db::open(&boot.data_dir).await?; let (settings, rev) = db::load_settings(&db).await?; @@ -73,6 +93,7 @@ impl App { loops: Mutex::new(std::collections::HashMap::new()), shutdown: tokio_util::sync::CancellationToken::new(), stale: Mutex::new(std::collections::HashMap::new()), + ad_trace: Mutex::new(std::collections::VecDeque::new()), })) } diff --git a/src/autodiscover.rs b/src/autodiscover.rs index c344371..2ef5cbf 100644 --- a/src/autodiscover.rs +++ b/src/autodiscover.rs @@ -96,12 +96,71 @@ pub async fn handler( .map(|(l, _)| l) .unwrap_or_else(|| "-".into()); let size = hdr("content-length").parse::().unwrap_or(0); - let resp = inner(app.clone(), peer, method.clone(), headers, body).await; - let mut resp = resp; + // 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", @@ -109,6 +168,18 @@ pub async fn handler( _ => "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, @@ -199,36 +270,67 @@ async fn inner( 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"); + // 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); + } } - 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(); + } + } + 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); diff --git a/src/lib.rs b/src/lib.rs index 6cf59db..df12a70 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -34,6 +34,7 @@ pub fn public_router(app: Arc) -> Router { .route("/autodiscover/autodiscover.xml", any(autodiscover::handler)) .route("/healthz", axum::routing::get(healthz)) .route("/", axum::routing::get(index)) + .route("/favicon.svg", axum::routing::get(favicon)) .fallback(not_found) .layer(axum::middleware::map_response(mark)) .with_state(app) @@ -47,10 +48,45 @@ async fn mark(mut r: axum::response::Response) -> axum::response::Response { r } +const LANDING: &str = include_str!("../web/landing.html"); +const FAVICON: &str = include_str!("../web/favicon.svg"); + +/// Landing page for browsers opening the ActiveSync host. Self-contained (inline SVG, one inline +/// style block pinned by a hash in the CSP, no scripts, no external requests). async fn index() -> impl axum::response::IntoResponse { + static CSP: std::sync::OnceLock = std::sync::OnceLock::new(); + let csp = CSP.get_or_init(|| { + use base64::Engine; + use sha2::{Digest, Sha256}; + let css = LANDING + .split_once("")) + .map(|(c, _)| c) + .unwrap_or(""); + let hash = base64::engine::general_purpose::STANDARD.encode(Sha256::digest(css.as_bytes())); + format!( + "default-src 'none'; style-src 'sha256-{hash}'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" + ) + }); ( - [("Content-Type", "text/plain; charset=utf-8")], - "ActivePush - Exchange ActiveSync endpoint.\nDevices connect to /Microsoft-Server-ActiveSync.\n", + [ + ("Content-Type", "text/html; charset=utf-8"), + ("Content-Security-Policy", csp.as_str()), + ("X-Content-Type-Options", "nosniff"), + ("Referrer-Policy", "no-referrer"), + ("Cache-Control", "no-cache"), + ], + LANDING, + ) +} + +async fn favicon() -> impl axum::response::IntoResponse { + ( + [ + ("Content-Type", "image/svg+xml"), + ("Cache-Control", "public, max-age=86400"), + ], + FAVICON, ) } diff --git a/web/app.js b/web/app.js index f907d03..bee4143 100644 --- a/web/app.js +++ b/web/app.js @@ -715,10 +715,19 @@ ['autodiscover.enabled', T('Włączony', 'Enabled'), 'bool'], ['autodiscover.require_auth', T('Wymagaj poprawnego hasła', 'Require valid password'), 'bool'], ['autodiscover.server_url', T('Publiczny URL ActiveSync', 'Public ActiveSync URL'), 'text', { wide: true, ph: 'https://mail.example.com/Microsoft-Server-ActiveSync', help: T('Adres przekazywany telefonom. Puste = z nagłówka Host żądania (zwykle autodiscover., co bywa błędne).', 'Address handed to phones. Empty = from the Host header of the request (usually autodiscover., which is often wrong).') }], ['autodiscover.domains', T('Domeny e-mail', 'E-mail domains'), 'list', { ph: 'example.com\nexample.org https://mail.example.org/Microsoft-Server-ActiveSync', help: T('Jedna domena na linię, opcjonalnie z własnym adresem ActiveSync po spacji (domyślnie: Publiczny URL powyżej). Gdy lista nie jest pusta, żądania dla innych domen są odrzucane (pusta = wszystkie domeny). Zapisz zmiany przed uruchomieniem diagnostyki.', 'One domain per line, optionally followed by its own ActiveSync URL after a space (default: the Public URL above). When the list is not empty, requests for other domains are refused (empty = all domains). Save before running diagnostics.') }], - ], `
`), + ], `

${T('Ostatnie żądania Autodiscover', 'Recent Autodiscover requests')}

${T('Nagłówki (bez haseł), treść żądania i odpowiedzi - do diagnozy telefonów. Ostatnie 30, tylko w pamięci.', 'Headers (no passwords), request and response bodies - for debugging phones. Last 30, memory only.')}
`), { bind: (form) => { const doms = (S.settings.autodiscover.domains || []).filter(Boolean).map((e) => e.trim().split(/[\s=]+/)[0].toLowerCase().replace(/\.$/, '')); const out = $('#diag-out', form); + const traceOut = $('#trace-out', form); + const loadTrace = async () => { + try { + const rows = await api('GET', '/api/autodiscover/trace'); + traceOut.innerHTML = rows.length ? rows.map((r) => `
${r.http} ${esc(new Date(r.ts * 1000).toLocaleTimeString())} ${esc(r.method)} ${esc(r.path)} ${esc(r.host)} - ${esc(r.ip)} - ${T('login', 'login')}: ${esc(r.typed_user || '-')} - ${r.ms} ms
${esc(r.request_headers.join('\n'))}
${esc(r.request_body || '(empty body)')}
${esc(r.response_body)}
`).join('') : `
${T('Brak żądań od ostatniego startu.', 'No requests since the last start.')}
`; + } catch (err) { traceOut.innerHTML = `
${esc(err.message)}
`; } + }; + $('#trace-refresh', form).addEventListener('click', loadTrace); + loadTrace(); $('#diag-hint', form).textContent = doms.length ? doms.map((d) => '@' + d).join(' ') : T('Dodaj domeny i zapisz, aby uruchomić diagnostykę.', 'Add domains and save to run diagnostics.'); $('#diag-all', form).disabled = !doms.length; $('#diag-all', form).addEventListener('click', async (e) => { diff --git a/web/landing.html b/web/landing.html new file mode 100644 index 0000000..f7ebca8 --- /dev/null +++ b/web/landing.html @@ -0,0 +1,21 @@ + + + + + + +ActivePush + + + + +a/p + + diff --git a/web/style.css b/web/style.css index 30bea5d..29f235a 100644 --- a/web/style.css +++ b/web/style.css @@ -368,3 +368,6 @@ td.wrap { white-space: normal; max-width: 420px; } .sub-h { margin: 16px 0 6px; font-size: 13px; } pre.code { margin: 0 0 8px; padding: 9px 11px; background: var(--code-bg); border: 1px solid var(--line); border-radius: var(--r-control); font: 11px/1.55 var(--mono); overflow-x: auto; white-space: pre; user-select: all; } td.mono-cell { font-family: var(--mono); font-size: 11px; } +details.trace { border-top: 1px solid var(--line); padding: 6px 0; } +details.trace summary { cursor: pointer; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } +details.trace pre.code { max-height: 260px; overflow: auto; margin: 6px 0; }