v0.1.8
This commit is contained in:
1 parent
b6e8ddb9c7
commit
818cae8592
12 files changed
+253
-33
No files matched your search
@@ -5,4 +5,3 @@ dist/
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
/*.*
|
||||
@@ -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
@@ -4,7 +4,7 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "active-push"
|
||||
version = "0.1.4"
|
||||
version = "0.1.8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
|
||||
+1
-1
@@ -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 |
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
Reference in new issue
Block a user