This commit is contained in:
Mateusz Gruszczyński committed 2026-11-08 12:57:19 +01:00
1 parent b6e8ddb9c7
commit 818cae8592
12 files changed
+253 -33

No files matched your search

-1
View File
@@ -5,4 +5,3 @@ dist/
*.db
*.db-wal
*.db-shm
/*.*
+23
View File
@@ -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.
Generated
+1 -1
View File
@@ -4,7 +4,7 @@ version = 4
[[package]]
name = "active-push"
version = "0.1.4"
version = "0.1.8"
dependencies = [
"anyhow",
"arc-swap",
+1 -1
View File
@@ -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"
Binary file not shown.

After

Width:  |  Height:  |  Size: 448 KiB

+6
View File
@@ -220,6 +220,7 @@ pub fn router(app: Arc<App>) -> 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<Arc<App>>) -> 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<Arc<App>>, Json(req): Json<AdCheckReq>) -> Response {
let s = app.settings();
+21
View File
@@ -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<std::collections::HashMap<i64, std::collections::BTreeSet<String>>>,
/// Last Autodiscover requests with headers (no secrets) and bodies, for the panel.
ad_trace: Mutex<std::collections::VecDeque<serde_json::Value>>,
}
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<serde_json::Value> {
self.ad_trace
.lock()
.unwrap()
.iter()
.rev()
.cloned()
.collect()
}
pub async fn new(boot: Boot, logs: Arc<LogRing>) -> anyhow::Result<Arc<App>> {
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()),
}))
}
+129 -27
View File
@@ -96,12 +96,71 @@ pub async fn handler(
.map(|(l, _)| l)
.unwrap_or_else(|| "-".into());
let size = hdr("content-length").parse::<u64>().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<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",
@@ -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<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);
}
}
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<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);
+38 -2
View File
@@ -34,6 +34,7 @@ pub fn public_router(app: Arc<app::App>) -> 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<String> = std::sync::OnceLock::new();
let csp = CSP.get_or_init(|| {
use base64::Engine;
use sha2::{Digest, Sha256};
let css = LANDING
.split_once("<style>")
.and_then(|(_, r)| r.split_once("</style>"))
.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,
)
}
+10 -1
View File
@@ -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.<domena>, co bywa błędne).', 'Address handed to phones. Empty = from the Host header of the request (usually autodiscover.<domain>, 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.') }],
], `<div class="diag-bar"><button class="outline" id="diag-all">${T('Uruchom diagnostykę wszystkich domen', 'Run diagnostics for all domains')}</button><span class="fine" id="diag-hint"></span></div><div id="diag-out"></div>`),
], `<div class="diag-bar"><button class="outline" id="diag-all">${T('Uruchom diagnostykę wszystkich domen', 'Run diagnostics for all domains')}</button><span class="fine" id="diag-hint"></span></div><div id="diag-out"></div><h3 class="sub-h">${T('Ostatnie żądania Autodiscover', 'Recent Autodiscover requests')}</h3><div class="diag-bar"><button class="outline" id="trace-refresh">${T('Odśwież', 'Refresh')}</button><span class="fine">${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.')}</span></div><div id="trace-out"></div>`),
{ 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) => `<details class="trace"><summary><span class="pill ${r.http === 200 ? 'ok' : 'warn'}">${r.http}</span> ${esc(new Date(r.ts * 1000).toLocaleTimeString())} ${esc(r.method)} ${esc(r.path)} <span class="fine">${esc(r.host)} - ${esc(r.ip)} - ${T('login', 'login')}: ${esc(r.typed_user || '-')} - ${r.ms} ms</span></summary><pre class="code">${esc(r.request_headers.join('\n'))}</pre><pre class="code">${esc(r.request_body || '(empty body)')}</pre><pre class="code">${esc(r.response_body)}</pre></details>`).join('') : `<div class="fine">${T('Brak żądań od ostatniego startu.', 'No requests since the last start.')}</div>`;
} catch (err) { traceOut.innerHTML = `<div class="result bad">${esc(err.message)}</div>`; }
};
$('#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) => {
+21
View File
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>ActivePush</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<style>
:root { color-scheme: dark; --bg: #131615; --line: #323a34; }
@media (prefers-color-scheme: light) { :root { color-scheme: light; --bg: #f2f4ee; --line: #cbd3c7; } }
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body { background: var(--bg); display: flex; align-items: center; justify-content: center; }
.mark { display: block; width: 128px; height: 128px; border: 1px solid var(--line); border-radius: 28px; }
</style>
</head>
<body>
<svg class="mark" viewBox="0 0 64 64" role="img" aria-label="ActivePush"><rect width="64" height="64" fill="#1b1f1d"/><text x="32" y="41" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Consolas, monospace" font-size="24" font-weight="700" fill="#f69b74">a<tspan fill="#cfe0ac">/</tspan>p</text></svg>
</body>
</html>
+3
View File
@@ -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; }