diff --git a/.gitignore b/.gitignore index 8ae292e..dfc87ba 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ dist/ *.db *.db-wal *.db-shm +/*.* diff --git a/CHANGELOG.md b/CHANGELOG.md index 51a2f48..b728700 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## 0.1.4 - 2026-11-07 + +- Autodiscover: several domains, each optionally with its own ActiveSync URL - one line per domain, e.g. + `example.org https://mail.example.org/Microsoft-Server-ActiveSync`. Without a URL the global Public ActiveSync URL + applies; an empty list serves every domain. Diagnostics use the per-domain URL. + +## 0.1.3 - 2026-11-07 + +- New panel page **Autodiscover**: list of served e-mail domains, public ActiveSync URL and per-domain diagnostics + (DNS A/AAAA/SRV, endpoint probes with certificate/redirect detection, ActiveSync URL, verdict, recommended DNS + records and nginx block). When a domain list is set, other domains are refused. +- Built-in minimal DNS client (no new dependencies). Responses carry an `X-ActivePush` marker so the diagnostics can + tell ActivePush from another application behind the proxy. + +## 0.1.2 - 2026-11-07 + +- Autodiscover: every attempt (including the unauthenticated first request iOS sends, wrong host, backend errors) + is now logged (`active_push::autodiscover`) and shown in Live with the host it arrived on; a request without + `AcceptableResponseSchema` is treated as MobileSync. See docs/TROUBLESHOOTING.md ("iPhone asks for the server"). + ## 0.1.1 - 2026-11-05 - Fix: devices migrated from Z-Push looping on unknown collection ids (`collection ... not found`) - such ids are diff --git a/Cargo.lock b/Cargo.lock index 23ba78e..0c38400 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "active-push" -version = "0.1.1" +version = "0.1.4" dependencies = [ "anyhow", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index 24ffd81..49874cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "active-push" -version = "0.1.1" +version = "0.1.4" edition = "2021" rust-version = "1.85" description = "ActivePush - Exchange ActiveSync server (Z-Push rewrite) with built-in administration panel" diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 77dfd16..ea8c259 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -71,3 +71,6 @@ Devices send the zone as a 172-byte Windows `TIME_ZONE_INFORMATION` structure. A zone, preferring `general.default_timezone` (comparing this year's DST rules). Recurring events are stored in CalDAV with `TZID` and a generated `VTIMEZONE`; single events in UTC (unless the original used `TZID`, in which case the format is kept). + +`autodiscover.domains` - e-mail domains Autodiscover is published for (used by the panel diagnostics; when +non-empty, requests for addresses of other domains are refused). diff --git a/docs/PANEL.md b/docs/PANEL.md index 7fdf60b..06c1cde 100644 --- a/docs/PANEL.md +++ b/docs/PANEL.md @@ -76,16 +76,41 @@ the form (also unsaved ones) and an optional test login; the password is never s ![ActiveSync](images/activesync.png) Login format and default domain, e-mail address template, protocol version, synchronisation limits, push -intervals, Autodiscover, logging and retention. +intervals, logging and retention. -## 07 Policies +## 07 Autodiscover + +![Autodiscover](images/autodiscover.png) + +Settings for the automatic account setup on phones plus a diagnostics tool. Phones look for Autodiscover on the +**domain of the e-mail address**, not on the server name: `https:///autodiscover/autodiscover.xml`, +`https://autodiscover./autodiscover/autodiscover.xml` or the DNS record `_autodiscover._tcp.`. + +- **E-mail domains** - domains Autodiscover is published for (one per line). When the list is not empty, requests + for other domains are refused. + One line per domain; optionally add a space and an ActiveSync URL for that domain + (`example.org https://mail.example.org/Microsoft-Server-ActiveSync`), otherwise the Public ActiveSync URL is used. + An empty list serves every domain. +- **Public ActiveSync URL** - the address handed to phones. Set it, otherwise it is derived from the request's + `Host` header (usually `autodiscover.`). +- **Run diagnostics** (saved domains only) checks, per domain: A/AAAA records of the domain, `autodiscover.` + and the ActiveSync host, the SRV record and its target, every candidate endpoint (TLS certificate, redirects, + whether ActivePush answers and not another application) and the ActiveSync URL. A verdict states whether + devices will find Autodiscover; when not, ready-to-use DNS records and an nginx server block are shown. + Redirects (for example to a web site or an old `.php` Autodiscover) are reported as failures because phones do + not reliably follow them. + +Every real Autodiscover request (including the unauthenticated first request and failures) appears in **Live** +with the host it arrived on. + +## 08 Policies ![Policies](images/policies.png) Provisioning switch, loose provisioning, named policies with every `policies.ini` option and per-mailbox assignments (`login=policy`, one per line). Changing a policy makes devices accept it again. -## 08 Security +## 09 Security ![Security](images/security.png) @@ -93,7 +118,7 @@ Login lockout thresholds, trusted proxies and client IP header, network allow li panel, allowed/blocked mailboxes and device types, device approval, panel session lifetime, Prometheus token, administrator password change and signing out other sessions. -## 09 Logs and audit +## 10 Logs and audit ![Logs](images/logs.png) diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 0be2beb..f2a355e 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -50,3 +50,20 @@ address requires `ACTIVE_PUSH_ALLOW_REMOTE_ADMIN=true` (preferably with TLS). Lo CLI commands must run as the service user so database files keep the right owner: `sudo -u active-push active-push ...`. + +## iPhone still asks for the server address + +iOS only runs Autodiscover when it can reach it for the **domain of the e-mail address**. It tries, in order, +`https:///autodiscover/autodiscover.xml`, `https://autodiscover./autodiscover/autodiscover.xml` +and the DNS SRV record `_autodiscover._tcp.`; a failure is silent and the phone shows the manual +"Server" field. Run the diagnostics on the **Autodiscover** page first (it checks DNS, SRV and every endpoint). Then open **Live** while adding the account: + +| What you see | Meaning / fix | +|---|---| +| nothing at all | the request never reaches ActivePush: no DNS record for `autodiscover.`, a certificate that does not cover that name, or no nginx `server_name` / locations for it. Add the name to the certificate and the three `location` blocks (see `deploy/nginx-active-push.conf`) | +| `401 auth required`, then nothing | iOS did not accept the answer or the TLS certificate; check the certificate chain with a browser | +| `503 backend unavailable` | IMAP login check failed, see Logs | +| `200 ok (host)` but the phone still asks | the returned URL is built from the host the request arrived on (e.g. `autodiscover.example.com`); set **Autodiscover -> Public ActiveSync URL** to the public ActiveSync URL that has a valid certificate | + +The e-mail address typed on the phone must be a full address (`user@domain`); the login is derived from it +according to the **Login** settings. diff --git a/docs/images/activesync.png b/docs/images/activesync.png index afa227c..2bc3b7d 100644 Binary files a/docs/images/activesync.png and b/docs/images/activesync.png differ diff --git a/docs/images/autodiscover.png b/docs/images/autodiscover.png new file mode 100644 index 0000000..d0e348d Binary files /dev/null and b/docs/images/autodiscover.png differ diff --git a/docs/images/backends.png b/docs/images/backends.png index 1b027e2..26160eb 100644 Binary files a/docs/images/backends.png and b/docs/images/backends.png differ diff --git a/docs/images/device.png b/docs/images/device.png index d61d5bb..e0c6c83 100644 Binary files a/docs/images/device.png and b/docs/images/device.png differ diff --git a/docs/images/devices.png b/docs/images/devices.png index 1a8ec5c..330a668 100644 Binary files a/docs/images/devices.png and b/docs/images/devices.png differ diff --git a/docs/images/live.png b/docs/images/live.png index 40ff1d6..3266abe 100644 Binary files a/docs/images/live.png and b/docs/images/live.png differ diff --git a/docs/images/logs.png b/docs/images/logs.png index 999d5a8..535a03a 100644 Binary files a/docs/images/logs.png and b/docs/images/logs.png differ diff --git a/docs/images/mailboxes.png b/docs/images/mailboxes.png index a01eafb..78e1f61 100644 Binary files a/docs/images/mailboxes.png and b/docs/images/mailboxes.png differ diff --git a/docs/images/mobile.png b/docs/images/mobile.png index 7211cc3..70e8a19 100644 Binary files a/docs/images/mobile.png and b/docs/images/mobile.png differ diff --git a/docs/images/overview-light.png b/docs/images/overview-light.png index f2b03e8..018c78c 100644 Binary files a/docs/images/overview-light.png and b/docs/images/overview-light.png differ diff --git a/docs/images/overview.png b/docs/images/overview.png index 774701e..00bb0d2 100644 Binary files a/docs/images/overview.png and b/docs/images/overview.png differ diff --git a/docs/images/policies.png b/docs/images/policies.png index 49dc649..5a66867 100644 Binary files a/docs/images/policies.png and b/docs/images/policies.png differ diff --git a/docs/images/security.png b/docs/images/security.png index aa81cc5..ad3f7d6 100644 Binary files a/docs/images/security.png and b/docs/images/security.png differ diff --git a/src/admin.rs b/src/admin.rs index 34e0c43..36960de 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -219,6 +219,7 @@ pub fn router(app: Arc) -> Router { .route("/api/users", get(users)) .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/logs", get(logs)) .route("/api/audit", get(audit)) .route("/api/lockouts", get(lockouts)) @@ -997,6 +998,27 @@ async fn test_backend( } } +#[derive(Deserialize)] +struct AdCheckReq { + domain: String, +} + +/// 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(); + let d = crate::autodiag::normalize_domain(&req.domain); + if !s.autodiscover.serves(&d) { + return err( + StatusCode::BAD_REQUEST, + "domain is not in the saved Autodiscover domain list - save the settings first", + ); + } + match tokio::time::timeout(Duration::from_secs(60), crate::autodiag::diagnose(&s, &d)).await { + Ok(v) => Json(v).into_response(), + Err(_) => err(StatusCode::GATEWAY_TIMEOUT, "diagnostics timed out"), + } +} + #[derive(Deserialize)] struct LogQ { level: Option, diff --git a/src/autodiag.rs b/src/autodiag.rs new file mode 100644 index 0000000..56a4f12 --- /dev/null +++ b/src/autodiag.rs @@ -0,0 +1,425 @@ +//! Autodiscover diagnostics for the panel: DNS (A/AAAA/SRV), endpoint probes and the ActiveSync URL. +//! Only domains saved in the settings can be checked. + +use std::time::Duration; + +use serde_json::{json, Value}; + +use crate::config::Settings; +use crate::dnscheck::{self, Answer}; + +const MOBILESYNC_REQ: &str = r#"diagnostics@invalidhttp://schemas.microsoft.com/exchange/autodiscover/mobilesync/responseschema/2006"#; + +/// Hostname syntax only (no IP literals, no ports, no paths). +pub fn valid_domain(d: &str) -> bool { + let d = d.trim_end_matches('.'); + if d.is_empty() || d.len() > 253 || !d.contains('.') { + return false; + } + if d.parse::().is_ok() { + return false; + } + d.split('.').all(|l| { + !l.is_empty() + && l.len() <= 63 + && !l.starts_with('-') + && !l.ends_with('-') + && l.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') + }) +} + +pub fn normalize_domain(d: &str) -> String { + d.trim().trim_end_matches('.').to_ascii_lowercase() +} + +struct Check { + level: &'static str, + title: String, + detail: String, +} + +fn ck(level: &'static str, title: impl Into, detail: impl Into) -> Check { + Check { + level, + title: title.into(), + detail: detail.into(), + } +} + +fn host_of(url: &str) -> Option { + url::Url::parse(url) + .ok() + .and_then(|u| u.host_str().map(|h| h.to_ascii_lowercase())) +} + +fn fmt_ips(a: &Answer) -> String { + let mut v: Vec = a + .cnames() + .into_iter() + .map(|c| format!("CNAME {c}")) + .collect(); + v.extend(a.ips().iter().map(|i| i.to_string())); + v.join(", ") +} + +async fn resolve(name: &str) -> Result { + let (a, aaaa) = tokio::join!( + dnscheck::query(name, dnscheck::T_A), + dnscheck::query(name, dnscheck::T_AAAA) + ); + match (a, aaaa) { + (Ok(mut a), Ok(b)) => { + a.records.extend(b.records); + Ok(a) + } + (Ok(a), Err(_)) => Ok(a), + (Err(e), Ok(b)) => { + let _ = e; + Ok(b) + } + (Err(e), Err(_)) => Err(format!("{e:#}")), + } +} + +async fn dns_check(name: &str, role: &str, out: &mut Vec) -> bool { + match resolve(name).await { + Ok(a) if !a.ips().is_empty() => { + out.push(ck("ok", format!("DNS {name}"), fmt_ips(&a))); + true + } + Ok(a) if a.nxdomain() => { + out.push(ck( + if role == "ActiveSync host" { + "fail" + } else { + "info" + }, + format!("DNS {name}"), + format!("no such name ({role})"), + )); + false + } + Ok(_) => { + out.push(ck( + if role == "ActiveSync host" { + "fail" + } else { + "info" + }, + format!("DNS {name}"), + format!("no A/AAAA record ({role})"), + )); + false + } + Err(e) => { + out.push(ck( + "fail", + format!("DNS {name}"), + format!("lookup failed: {e}"), + )); + false + } + } +} + +fn client(insecure: bool) -> reqwest::Client { + reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(8)) + .user_agent(concat!( + "ActivePush-Diagnostics/", + env!("CARGO_PKG_VERSION") + )) + .danger_accept_invalid_certs(insecure) + .build() + .unwrap_or_default() +} + +struct Probe { + status: u16, + location: String, + www_auth: String, + by_us: bool, + eas: bool, +} + +async fn send(url: &str, options: bool, insecure: bool) -> Result { + let c = client(insecure); + let rb = if options { + c.request(reqwest::Method::OPTIONS, url) + } else { + c.post(url) + .header("Content-Type", "text/xml") + .body(MOBILESYNC_REQ) + }; + let r = rb.send().await?; + let h = |n: &str| { + r.headers() + .get(n) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .chars() + .filter(|c| !c.is_control()) + .take(200) + .collect::() + }; + Ok(Probe { + status: r.status().as_u16(), + location: h("location"), + www_auth: h("www-authenticate"), + by_us: r.headers().contains_key("x-activepush"), + eas: r.headers().contains_key("ms-server-activesync") + || r.headers().contains_key("ms-asprotocolversions"), + }) +} + +/// Probe one URL; `options` = ActiveSync endpoint (OPTIONS) instead of Autodiscover (POST). +async fn probe(url: &str, options: bool, out: &mut Vec) -> bool { + let (p, tls_note) = match send(url, options, false).await { + Ok(p) => (p, None), + Err(e) => match send(url, options, true).await { + Ok(p) => (p, Some(format!("{e}"))), + Err(e2) => { + out.push(ck("fail", url, format!("cannot connect: {e2}"))); + return false; + } + }, + }; + let mut good = false; + let (mut level, msg): (&'static str, String) = if options { + match p.status { + 200 | 401 if p.eas || p.by_us => { + good = true; + ("ok", format!("HTTP {} - ActiveSync endpoint answers (401 is expected without credentials)", p.status)) + } + 200 | 401 => ( + "warn", + format!("HTTP {} but the answer is not recognised as ActivePush (another application, or ActivePush older than 0.1.3)", p.status), + ), + s => ("fail", format!("HTTP {s}")), + } + } else { + match p.status { + 401 if p.www_auth.to_ascii_lowercase().starts_with("basic") => { + if p.by_us { + good = true; + ("ok", "HTTP 401 Basic challenge, answered by ActivePush (this is what iOS expects)".into()) + } else { + ("warn", "HTTP 401 Basic challenge, but not recognised as ActivePush (another application, or ActivePush older than 0.1.3 - upgrade to see the marker)".into()) + } + } + 401 => ("warn", format!("HTTP 401 with unexpected challenge \"{}\"", p.www_auth)), + 200 if p.by_us => { + good = true; + ("ok", "HTTP 200, answered by ActivePush".into()) + } + 200 => ("warn", "HTTP 200 but not recognised as ActivePush (another application, or ActivePush older than 0.1.3)".into()), + 301 | 302 | 303 | 307 | 308 => ( + "fail", + format!( + "HTTP {} redirect to {} - devices do not reliably follow redirects for Autodiscover; proxy the request to ActivePush instead", + p.status, p.location + ), + ), + 404 | 405 => ("fail", format!("HTTP {} - Autodiscover is not served here", p.status)), + s => ("fail", format!("HTTP {s}")), + } + }; + let mut detail = msg; + if let Some(e) = tls_note { + level = "fail"; + good = false; + detail = format!("TLS certificate not trusted for this host ({e}). Behind it: {detail}"); + } + out.push(ck(level, url, detail)); + good +} + +pub async fn diagnose(s: &Settings, domain: &str) -> Value { + let mut checks: Vec = Vec::new(); + let d = domain; + let ad_host = format!("autodiscover.{d}"); + + // public ActiveSync URL + let server_url = s.autodiscover.url_for(domain); + let mail_host = host_of(&server_url); + if !s.autodiscover.enabled { + checks.push(ck("fail", "Autodiscover", "disabled in settings")); + } + match (&mail_host, server_url.is_empty()) { + (Some(h), _) if server_url.starts_with("https://") => checks.push(ck( + "ok", + "Public ActiveSync URL", + format!("{server_url} (host {h})"), + )), + (Some(_), _) => checks.push(ck( + "warn", + "Public ActiveSync URL", + "should start with https:// - iOS refuses plain HTTP servers", + )), + (None, true) => checks.push(ck( + "warn", + "Public ActiveSync URL", + "not set - the URL is derived from the Host header of each request, which is the Autodiscover host (e.g. autodiscover.) and may not have a valid certificate for ActiveSync. Set it above.", + )), + (None, false) => checks.push(ck("fail", "Public ActiveSync URL", "not a valid URL")), + } + + // DNS + let apex_ok = dns_check(d, "mail domain", &mut checks).await; + let ad_ok = dns_check(&ad_host, "iOS tries this name", &mut checks).await; + if let Some(h) = &mail_host { + dns_check(h, "ActiveSync host", &mut checks).await; + } + let srv_name = format!("_autodiscover._tcp.{d}"); + let mut srv_target: Option<(String, u16)> = None; + match dnscheck::query(&srv_name, dnscheck::T_SRV).await { + Ok(a) => { + let list = a.srv(); + if list.is_empty() { + checks.push(ck( + "info", + format!("SRV {srv_name}"), + "no record (optional; used by iOS when the names above do not work)", + )); + } else { + let txt = list + .iter() + .map(|(p, w, port, t)| format!("{p} {w} {port} {t}")) + .collect::>() + .join("; "); + let (_, _, port, target) = list[0].clone(); + let target = target.trim_end_matches('.').to_ascii_lowercase(); + if port != 443 { + checks.push(ck( + "warn", + format!("SRV {srv_name}"), + format!("{txt} - port should be 443"), + )); + } else { + checks.push(ck("ok", format!("SRV {srv_name}"), txt)); + } + if valid_domain(&target) { + dns_check(&target, "SRV target", &mut checks).await; + srv_target = Some((target, port)); + } + } + } + Err(e) => checks.push(ck( + "fail", + format!("SRV {srv_name}"), + format!("lookup failed: {e:#}"), + )), + } + + // Autodiscover endpoints (the order iOS uses) + let path = "/autodiscover/autodiscover.xml"; + let mut working: Vec = Vec::new(); + let mut candidates: Vec<(String, bool)> = vec![]; + if apex_ok { + candidates.push((format!("https://{d}{path}"), true)); + } + if ad_ok { + candidates.push((format!("https://{ad_host}{path}"), true)); + } + if let Some((t, port)) = &srv_target { + let u = if *port == 443 { + format!("https://{t}{path}") + } else { + format!("https://{t}:{port}{path}") + }; + if !candidates.iter().any(|(c, _)| *c == u) { + candidates.push((u, true)); + } + } + if let Some(h) = &mail_host { + let u = format!("https://{h}{path}"); + if !candidates.iter().any(|(c, _)| *c == u) { + candidates.push((u, false)); + } + } + for (u, counts) in &candidates { + let before = checks.len(); + let ok = probe(u, false, &mut checks).await; + if !counts { + // informational: not a name iOS derives from the e-mail domain + if let Some(c) = checks.get_mut(before) { + c.detail = format!( + "{} (not derived from the e-mail domain by devices; shown for reference)", + c.detail + ); + } + } else if ok { + working.push(u.clone()); + } + } + + // ActiveSync endpoint itself + if let Some(h) = &mail_host { + if server_url.starts_with("https://") { + probe(&server_url, true, &mut checks).await; + let _ = h; + } + } + + // verdict + let verdict = if working.is_empty() { + ck( + "fail", + "Verdict", + format!("no working Autodiscover path for @{d}: iOS will ask for the server address. Apply the recommendations below."), + ) + } else { + ck( + "ok", + "Verdict", + format!( + "devices find Autodiscover for @{d} via {}", + working.join(", ") + ), + ) + }; + checks.insert(0, verdict); + + let target = mail_host + .clone() + .unwrap_or_else(|| "mail.example.com".into()); + let dns_rec = vec![ + format!("_autodiscover._tcp.{d}. IN SRV 0 0 443 {target}."), + format!("{ad_host}. IN CNAME {target}. ; alternative to the SRV record"), + ]; + let nginx = format!( + "# server block for {ad_host} (certificate must cover this name)\n\ + server {{\n listen 443 ssl;\n http2 on;\n server_name {ad_host};\n\n\ + \x20 location ~* ^/autodiscover/autodiscover\\.xml$ {{\n\ + \x20 proxy_pass http://127.0.0.1:8081;\n\ + \x20 proxy_set_header Host $host;\n\ + \x20 proxy_set_header X-Forwarded-For $remote_addr;\n\ + \x20 proxy_set_header X-Forwarded-Proto $scheme;\n\ + \x20 client_max_body_size 64k;\n }}\n location / {{ return 404; }}\n}}\n" + ); + json!({ + "domain": d, + "checks": checks.iter().map(|c| json!({"level": c.level, "title": c.title, "detail": c.detail})).collect::>(), + "dns_records": dns_rec, + "nginx": nginx, + "working": working, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn domain_validation() { + assert!(valid_domain("linuxiarz.pl")); + assert!(valid_domain("mail.example.co.uk")); + assert!(!valid_domain("localhost")); + assert!(!valid_domain("10.0.0.1")); + assert!(!valid_domain("a b.pl")); + assert!(!valid_domain("-a.pl")); + assert!(!valid_domain("example.com/path")); + assert!(!valid_domain("example.com:443")); + } +} diff --git a/src/autodiscover.rs b/src/autodiscover.rs index 1b724f9..c344371 100644 --- a/src/autodiscover.rs +++ b/src/autodiscover.rs @@ -62,16 +62,80 @@ fn safe_host(h: &str) -> Option<&str> { .then_some(h) } +/// Every attempt is logged and shown in the panel (Live), including the unauthenticated +/// first request iOS sends and failures - this is what makes "my phone still asks for the +/// server" debuggable. pub async fn handler( State(app): State>, ConnectInfo(peer): ConnectInfo, method: Method, + uri: axum::http::Uri, + headers: HeaderMap, + body: Body, +) -> Response { + let s = app.settings(); + let started = std::time::Instant::now(); + let ip = client_ip(peer, &headers, &s).to_string(); + let hdr = |n: &str| { + headers + .get(n) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .chars() + .filter(|c| !c.is_control()) + .take(120) + .collect::() + }; + let host = if hdr("x-forwarded-host").is_empty() { + hdr("host") + } else { + hdr("x-forwarded-host") + }; + let ua = hdr("user-agent"); + let user = basic_auth(&headers, &s) + .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; + resp.headers_mut() + .insert("X-ActivePush", HeaderValue::from_static("autodiscover")); + let code = resp.status().as_u16(); + let status = match code { + 200 => "ok", + 401 => "auth required", + 429 => "locked out", + 503 => "backend unavailable", + _ => "error", + }; + tracing::info!(target: "active_push::autodiscover", ip = %ip, host = %host, path = %uri.path(), method = %method, http = code, user = %user, ua = %ua, "autodiscover {status}"); + app.metrics.record(Event { + ts: now(), + user, + device_id: String::new(), + device_type: "Autodiscover".into(), + command: "Autodiscover".into(), + ip, + http: code, + status: format!("{status} ({host})"), + ms: started.elapsed().as_millis() as u64, + bytes_in: size, + bytes_out: 0, + items_out: 0, + items_in: 0, + }); + resp +} + +async fn inner( + app: Arc, + peer: 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 @@ -101,11 +165,22 @@ pub async fn handler( Err(_) => return xml_response(StatusCode::OK, error_xml(600, "Invalid Request")), } }; - if schema != MOBILESYNC { - tracing::info!(ip = %ip, schema = %schema, "autodiscover: unsupported response schema"); + if !schema.is_empty() && schema != MOBILESYNC { + tracing::info!(ip = %ip, schema = %schema, "autodiscover: unsupported response schema (only MobileSync is served)"); return xml_response(StatusCode::OK, error_xml(600, "Invalid Request")); } + // only the configured domains (when a list is set) + if !s.autodiscover.domains.is_empty() { + if let Some((_, dom)) = email.rsplit_once('@') { + let dom = crate::autodiag::normalize_domain(dom); + if !s.autodiscover.serves(&dom) { + tracing::info!(ip = %ip, domain = %dom, "autodiscover: domain not in the configured list"); + 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) { @@ -158,8 +233,12 @@ pub async fn handler( 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() + let configured = email + .rsplit_once('@') + .map(|(_, d)| s.autodiscover.url_for(d)) + .unwrap_or_else(|| s.autodiscover.server_url.trim().to_string()); + let url = if !configured.is_empty() { + configured } else { let host = headers .get("x-forwarded-host") @@ -195,21 +274,5 @@ pub async fn handler( 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) } diff --git a/src/config.rs b/src/config.rs index c012791..4fbba5d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -454,6 +454,53 @@ pub struct Autodiscover { pub server_url: String, /// Validate credentials against IMAP before answering (recommended). pub require_auth: bool, + /// E-mail domains Autodiscover is published for, optionally `domain https://host/Microsoft-Server-ActiveSync`. Used by the DNS/endpoint diagnostics in the + /// panel; when non-empty, requests for addresses of other domains are refused. + pub domains: Vec, +} + +impl Autodiscover { + /// A list entry is `domain` or `domain ` (whitespace or `=` separated). + pub fn split_entry(e: &str) -> (String, Option) { + let e = e.trim(); + match e.split_once(|c: char| c.is_whitespace() || c == '=') { + Some((d, u)) => { + let u = u + .trim_start_matches(|c: char| c.is_whitespace() || c == '=') + .trim(); + ( + crate::autodiag::normalize_domain(d), + (!u.is_empty()).then(|| u.to_string()), + ) + } + None => (crate::autodiag::normalize_domain(e), None), + } + } + + /// Served domains without their optional URLs. + pub fn domain_names(&self) -> Vec { + self.domains + .iter() + .map(|e| Self::split_entry(e).0) + .filter(|d| !d.is_empty()) + .collect() + } + + pub fn serves(&self, domain: &str) -> bool { + let d = crate::autodiag::normalize_domain(domain); + self.domain_names().contains(&d) + } + + /// ActiveSync URL for a domain: its own override, else the global setting (empty = derive from Host). + pub fn url_for(&self, domain: &str) -> String { + let d = crate::autodiag::normalize_domain(domain); + self.domains + .iter() + .map(|e| Self::split_entry(e)) + .find(|(n, _)| *n == d) + .and_then(|(_, u)| u) + .unwrap_or_else(|| self.server_url.trim().to_string()) + } } impl Default for Autodiscover { @@ -462,6 +509,7 @@ impl Default for Autodiscover { enabled: true, server_url: String::new(), require_auth: true, + domains: vec![], } } } @@ -566,6 +614,25 @@ pub const SECRET_FIELDS: &[&str] = &["/smtp/password", "/panel/prometheus_token" impl Settings { pub fn validate(&self) -> Result<(), String> { + if self.autodiscover.domains.len() > 50 { + return Err("at most 50 Autodiscover domains".into()); + } + for e in &self.autodiscover.domains { + let (d, url) = Autodiscover::split_entry(e); + if !crate::autodiag::valid_domain(&d) { + return Err(format!("invalid Autodiscover domain: {e}")); + } + if let Some(u) = url { + let ok = url::Url::parse(&u) + .map(|p| p.scheme() == "https" && p.host_str().is_some()) + .unwrap_or(false); + if !ok { + return Err(format!( + "invalid ActiveSync URL for {d} (needs https://...): {u}" + )); + } + } + } if self.imap.host.trim().is_empty() { return Err("IMAP host is required".into()); } @@ -733,6 +800,38 @@ pub fn expand_user_template(tpl: &str, login: &str, default_domain: &str) -> Str #[cfg(test)] mod tests { + #[test] + fn autodiscover_domain_entries() { + let mut a = Autodiscover { + server_url: "https://mail.a.pl/Microsoft-Server-ActiveSync".into(), + domains: vec![ + "A.pl".into(), + "b.pl https://mail.b.pl/Microsoft-Server-ActiveSync".into(), + "c.pl=https://x.c.pl/Microsoft-Server-ActiveSync".into(), + ], + ..Default::default() + }; + assert!(a.serves("a.PL") && a.serves("b.pl") && a.serves("c.pl") && !a.serves("d.pl")); + assert_eq!( + a.url_for("a.pl"), + "https://mail.a.pl/Microsoft-Server-ActiveSync" + ); + assert_eq!( + a.url_for("b.pl"), + "https://mail.b.pl/Microsoft-Server-ActiveSync" + ); + assert_eq!( + a.url_for("c.pl"), + "https://x.c.pl/Microsoft-Server-ActiveSync" + ); + a.domains.push("e.pl http://insecure/x".into()); + let s = Settings { + autodiscover: a, + ..Default::default() + }; + assert!(s.validate().is_err()); + } + use super::*; #[test] diff --git a/src/dnscheck.rs b/src/dnscheck.rs new file mode 100644 index 0000000..9398231 --- /dev/null +++ b/src/dnscheck.rs @@ -0,0 +1,311 @@ +//! Minimal DNS client for the Autodiscover diagnostics (A, AAAA, CNAME, SRV). +//! Uses the resolvers from /etc/resolv.conf (UDP, TCP fallback when truncated). + +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; +use std::time::Duration; + +use anyhow::{anyhow, bail, Context, Result}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpStream, UdpSocket}; + +pub const T_A: u16 = 1; +pub const T_CNAME: u16 = 5; +pub const T_AAAA: u16 = 28; +pub const T_SRV: u16 = 33; + +#[derive(Debug, Clone, PartialEq)] +pub enum Rr { + A(Ipv4Addr), + Aaaa(Ipv6Addr), + Cname(String), + Srv { + prio: u16, + weight: u16, + port: u16, + target: String, + }, +} + +#[derive(Debug, Clone, Default)] +pub struct Answer { + /// DNS RCODE (0 = NOERROR, 3 = NXDOMAIN). + pub rcode: u8, + pub records: Vec, +} + +impl Answer { + pub fn nxdomain(&self) -> bool { + self.rcode == 3 + } + pub fn ips(&self) -> Vec { + self.records + .iter() + .filter_map(|r| match r { + Rr::A(a) => Some(IpAddr::V4(*a)), + Rr::Aaaa(a) => Some(IpAddr::V6(*a)), + _ => None, + }) + .collect() + } + pub fn cnames(&self) -> Vec { + self.records + .iter() + .filter_map(|r| match r { + Rr::Cname(c) => Some(c.clone()), + _ => None, + }) + .collect() + } + pub fn srv(&self) -> Vec<(u16, u16, u16, String)> { + let mut v: Vec<_> = self + .records + .iter() + .filter_map(|r| match r { + Rr::Srv { + prio, + weight, + port, + target, + } => Some((*prio, *weight, *port, target.clone())), + _ => None, + }) + .collect(); + v.sort(); + v + } +} + +fn resolvers() -> Vec { + let mut v = Vec::new(); + if let Ok(t) = std::fs::read_to_string("/etc/resolv.conf") { + for l in t.lines() { + let mut it = l.split_whitespace(); + if it.next() == Some("nameserver") { + if let Some(ip) = it.next().and_then(|s| s.split('%').next()) { + if let Ok(ip) = ip.parse::() { + v.push(SocketAddr::new(ip, 53)); + } + } + } + } + } + v.truncate(3); + if v.is_empty() { + v.push(SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), 53)); + } + v +} + +fn build_query(id: u16, name: &str, qtype: u16) -> Result> { + let mut b = Vec::with_capacity(64); + b.extend_from_slice(&id.to_be_bytes()); + b.extend_from_slice(&[0x01, 0x00, 0, 1, 0, 0, 0, 0, 0, 0]); + for label in name.trim_end_matches('.').split('.') { + if label.is_empty() || label.len() > 63 { + bail!("invalid DNS name"); + } + b.push(label.len() as u8); + b.extend_from_slice(label.as_bytes()); + } + b.push(0); + b.extend_from_slice(&qtype.to_be_bytes()); + b.extend_from_slice(&1u16.to_be_bytes()); + Ok(b) +} + +fn read_name(msg: &[u8], pos: &mut usize) -> Result { + let mut out = String::new(); + let mut p = *pos; + let mut jumped = false; + let mut hops = 0; + loop { + let len = *msg.get(p).ok_or_else(|| anyhow!("truncated name"))? as usize; + if len == 0 { + p += 1; + break; + } + if len & 0xC0 == 0xC0 { + let lo = *msg.get(p + 1).ok_or_else(|| anyhow!("truncated pointer"))? as usize; + if !jumped { + *pos = p + 2; + } + jumped = true; + p = ((len & 0x3F) << 8) | lo; + hops += 1; + if hops > 16 { + bail!("compression loop"); + } + continue; + } + let label = msg + .get(p + 1..p + 1 + len) + .ok_or_else(|| anyhow!("truncated label"))?; + if !out.is_empty() { + out.push('.'); + } + out.push_str(&String::from_utf8_lossy(label)); + p += 1 + len; + } + if !jumped { + *pos = p; + } + Ok(out) +} + +/// Returns (truncated, answer). +pub fn parse_response(msg: &[u8], id: u16) -> Result<(bool, Answer)> { + if msg.len() < 12 { + bail!("short DNS response"); + } + if u16::from_be_bytes([msg[0], msg[1]]) != id { + bail!("DNS id mismatch"); + } + let flags = u16::from_be_bytes([msg[2], msg[3]]); + let truncated = flags & 0x0200 != 0; + let rcode = (flags & 0x000F) as u8; + let qd = u16::from_be_bytes([msg[4], msg[5]]) as usize; + let an = u16::from_be_bytes([msg[6], msg[7]]) as usize; + let mut pos = 12; + for _ in 0..qd { + read_name(msg, &mut pos)?; + pos += 4; + } + let mut records = Vec::new(); + for _ in 0..an { + read_name(msg, &mut pos)?; + let h = msg + .get(pos..pos + 10) + .ok_or_else(|| anyhow!("truncated RR"))?; + let rtype = u16::from_be_bytes([h[0], h[1]]); + let rdlen = u16::from_be_bytes([h[8], h[9]]) as usize; + pos += 10; + let rd_start = pos; + let rd = msg + .get(pos..pos + rdlen) + .ok_or_else(|| anyhow!("truncated RDATA"))?; + match rtype { + T_A if rdlen == 4 => records.push(Rr::A(Ipv4Addr::new(rd[0], rd[1], rd[2], rd[3]))), + T_AAAA if rdlen == 16 => { + let mut o = [0u8; 16]; + o.copy_from_slice(rd); + records.push(Rr::Aaaa(Ipv6Addr::from(o))); + } + T_CNAME => { + let mut p = rd_start; + records.push(Rr::Cname(read_name(msg, &mut p)?)); + } + T_SRV if rdlen >= 7 => { + let mut p = rd_start + 6; + records.push(Rr::Srv { + prio: u16::from_be_bytes([rd[0], rd[1]]), + weight: u16::from_be_bytes([rd[2], rd[3]]), + port: u16::from_be_bytes([rd[4], rd[5]]), + target: read_name(msg, &mut p)?, + }); + } + _ => {} + } + pos = rd_start + rdlen; + } + Ok((truncated, Answer { rcode, records })) +} + +async fn ask(server: SocketAddr, q: &[u8], id: u16) -> Result { + let bind = if server.is_ipv4() { + "0.0.0.0:0" + } else { + "[::]:0" + }; + let sock = UdpSocket::bind(bind).await?; + sock.connect(server).await?; + sock.send(q).await?; + let mut buf = vec![0u8; 4096]; + let n = tokio::time::timeout(Duration::from_secs(3), sock.recv(&mut buf)) + .await + .context("timeout")??; + let (trunc, ans) = parse_response(&buf[..n], id)?; + if !trunc { + return Ok(ans); + } + let mut tcp = tokio::time::timeout(Duration::from_secs(3), TcpStream::connect(server)) + .await + .context("tcp timeout")??; + let mut framed = (q.len() as u16).to_be_bytes().to_vec(); + framed.extend_from_slice(q); + tcp.write_all(&framed).await?; + let mut len = [0u8; 2]; + tokio::time::timeout(Duration::from_secs(3), tcp.read_exact(&mut len)).await??; + let mut body = vec![0u8; u16::from_be_bytes(len) as usize]; + tokio::time::timeout(Duration::from_secs(3), tcp.read_exact(&mut body)).await??; + Ok(parse_response(&body, id)?.1) +} + +pub async fn query(name: &str, qtype: u16) -> Result { + let id: u16 = rand::random(); + let q = build_query(id, name, qtype)?; + let mut last = anyhow!("no resolver"); + for s in resolvers() { + match ask(s, &q, id).await { + Ok(a) => return Ok(a), + Err(e) => last = e.context(format!("resolver {s}")), + } + } + Err(last) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_srv_with_compression() { + // header: id 0x1234, QR+RD+RA, 1 question, 1 answer + let mut m = vec![0x12, 0x34, 0x81, 0x80, 0, 1, 0, 1, 0, 0, 0, 0]; + for l in ["_autodiscover", "_tcp", "example", "com"] { + m.push(l.len() as u8); + m.extend_from_slice(l.as_bytes()); + } + m.push(0); + m.extend_from_slice(&[0, 33, 0, 1]); + m.extend_from_slice(&[0xC0, 12, 0, 33, 0, 1, 0, 0, 1, 44]); + // rdata: prio 0 weight 0 port 443 target mail. + let mut rd = vec![0, 0, 0, 0, 0x01, 0xBB, 4]; + rd.extend_from_slice(b"mail"); + rd.extend_from_slice(&[0xC0, 31]); + m.extend_from_slice(&(rd.len() as u16).to_be_bytes()); + m.extend_from_slice(&rd); + let (tr, a) = parse_response(&m, 0x1234).unwrap(); + assert!(!tr); + assert_eq!(a.srv(), vec![(0, 0, 443, "mail.example.com".to_string())]); + } + + #[test] + fn query_roundtrip_header() { + let q = build_query(7, "a.b.", T_A).unwrap(); + assert_eq!(&q[..2], &[0, 7]); + assert_eq!(q[12], 1); + assert!(build_query(1, "a..b", T_A).is_err()); + } +} + +#[cfg(test)] +mod fixture_tests { + use super::*; + + // Real responses captured from a public resolver (DoH wire format, id 0). + #[test] + fn parses_real_srv_response() { + let m = include_bytes!("../tests/fixtures/dns-srv.bin"); + let (tr, a) = parse_response(m, 0).unwrap(); + assert!(!tr); + assert_eq!(a.srv()[0].2, 443); + assert_eq!(a.srv()[0].3, "autodiscover.linuxiarz.pl"); + } + + #[test] + fn parses_real_a_response() { + let m = include_bytes!("../tests/fixtures/dns-a.bin"); + let (_, a) = parse_response(m, 0).unwrap(); + assert_eq!(a.ips(), vec![IpAddr::V4(Ipv4Addr::new(188, 68, 237, 167))]); + } +} diff --git a/src/lib.rs b/src/lib.rs index 82cad61..6cf59db 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,10 +2,12 @@ pub mod admin; pub mod app; +pub mod autodiag; pub mod autodiscover; pub mod backend; pub mod config; pub mod db; +pub mod dnscheck; pub mod eas; pub mod logging; pub mod metrics; @@ -33,9 +35,18 @@ pub fn public_router(app: Arc) -> Router { .route("/healthz", axum::routing::get(healthz)) .route("/", axum::routing::get(index)) .fallback(not_found) + .layer(axum::middleware::map_response(mark)) .with_state(app) } +/// Marker used by the panel diagnostics to tell ActivePush from another application behind the proxy. +async fn mark(mut r: axum::response::Response) -> axum::response::Response { + r.headers_mut() + .entry("X-ActivePush") + .or_insert(axum::http::HeaderValue::from_static("1")); + r +} + async fn index() -> impl axum::response::IntoResponse { ( [("Content-Type", "text/plain; charset=utf-8")], diff --git a/tests/fixtures/dns-a.bin b/tests/fixtures/dns-a.bin new file mode 100644 index 0000000..2eab996 Binary files /dev/null and b/tests/fixtures/dns-a.bin differ diff --git a/tests/fixtures/dns-srv.bin b/tests/fixtures/dns-srv.bin new file mode 100644 index 0000000..ac85cd3 Binary files /dev/null and b/tests/fixtures/dns-srv.bin differ diff --git a/tools/dev/screenshots.js b/tools/dev/screenshots.js index b943bc2..ea7bfc6 100644 --- a/tools/dev/screenshots.js +++ b/tools/dev/screenshots.js @@ -46,6 +46,15 @@ const path = require('path'); await page.waitForTimeout(wait); await shot(id === 'users' ? 'mailboxes' : id, full); } + // Autodiscover: run the diagnostics for the first configured domain + await page.goto(`${base}/#autodiscover`); + await page.waitForTimeout(1500); + if (await page.isEnabled('#diag-all')) { + await page.click('#diag-all'); + await page.waitForSelector('#diag-out table', { timeout: 60000 }); + await page.waitForTimeout(500); + await shot('autodiscover', true); + } await page.goto(`${base}/#devices`); await page.waitForTimeout(2000); const chips = await page.$$('.chip[data-pid]'); diff --git a/web/app.js b/web/app.js index 43f3ff4..f907d03 100644 --- a/web/app.js +++ b/web/app.js @@ -203,6 +203,7 @@ ['users', T('Skrzynki', 'Mailboxes')], ['backends', T('Backendy', 'Backends')], ['activesync', 'ActiveSync'], + ['autodiscover', 'Autodiscover'], ['policies', T('Polityki', 'Policies')], ['security', T('Bezpieczeństwo', 'Security')], ['logs', T('Logi i audyt', 'Logs & audit')], @@ -645,7 +646,7 @@ ['carddav.include_regex', 'Include regex', 'text'], ['carddav.exclude_regex', 'Exclude regex', 'text'], ['carddav.timeout_secs', 'Timeout (s)', 'number'], ], testBlock('carddav')), { bind: bindTests }); - PAGES.activesync = (v) => settingsPage(v, 'PROTOCOL', 'ActiveSync', T('Logowanie, protokół, synchronizacja, push i Autodiscover.', 'Login, protocol, synchronisation, push and Autodiscover.'), (s) => + PAGES.activesync = (v) => settingsPage(v, 'PROTOCOL', 'ActiveSync', T('Logowanie, protokół, synchronizacja i push.', 'Login, protocol, synchronisation and push.'), (s) => section(s, T('Ogólne', 'General'), '', [ ['general.server_name', T('Nazwa serwera', 'Server name'), 'text'], ['general.default_timezone', T('Domyślna strefa czasowa', 'Default time zone'), 'tz'], ['general.login_format', T('Format loginu', 'Login format'), 'select', { options: [['as_given', T('jak podany przez urządzenie', 'as given by device')], ['local_part', T('tylko część lokalna (bez @domena)', 'local part only')], ['full_email', T('pełny e-mail (dopisz domenę)', 'full e-mail (append domain)')]], help: T('Odpowiednik USE_FULLEMAIL_FOR_LOGIN. Prefiks DOMENA\\ jest zawsze usuwany.', 'Equivalent of USE_FULLEMAIL_FOR_LOGIN. DOMAIN\\ prefixes are always stripped.') }], @@ -668,10 +669,6 @@ ['ping.lifetime_min', T('Min. heartbeat (s)', 'Min heartbeat (s)'), 'number'], ['ping.lifetime_max', T('Maks. heartbeat (s)', 'Max heartbeat (s)'), 'number'], ['ping.max_folders', T('Maks. folderów', 'Max folders'), 'number'], ]) + - section(s, 'Autodiscover', T('Automatyczna konfiguracja kont na telefonach (/autodiscover/autodiscover.xml).', 'Automatic account configuration (/autodiscover/autodiscover.xml).'), [ - ['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('Puste = wyznaczony z nagłówka Host.', 'Empty = derived from the Host header.') }], - ]) + section(s, T('Logowanie i retencja', 'Logging & retention'), '', [ ['logging.log_wbxml', T('Loguj zdekodowany WBXML (debug, zawiera treść poczty!)', 'Log decoded WBXML (debug, contains mail data!)'), 'bool'], ['logging.debug_users', T('Użytkownicy z logowaniem debug', 'Debug users'), 'list'], @@ -700,6 +697,44 @@ ['reqencsmimemessages', T('Wymagaj szyfrowania S/MIME', 'Require encrypted S/MIME'), 'bool'], ['allowsmimesoftcerts', T('Certyfikaty programowe S/MIME', 'S/MIME soft certs'), 'bool'], ]; + // Autodiscover: settings, served domains and DNS / endpoint diagnostics + const LEVEL_LABEL = { ok: 'OK', warn: T('UWAGA', 'WARN'), fail: T('BŁĄD', 'FAIL'), info: 'INFO' }; + const LEVEL_CLASS = { ok: 'ok', warn: 'warn', fail: 'bad', info: 'info' }; + + function diagHtml(r) { + const rows = r.checks.map((c) => `${esc(LEVEL_LABEL[c.level] || c.level)}${esc(c.title)}${esc(c.detail)}`).join(''); + const verdictFail = r.checks[0] && r.checks[0].level === 'fail'; + const advice = verdictFail ? `

${T('Zalecane rekordy DNS (wystarczy jeden z nich)', 'Recommended DNS records (one of them is enough)')}

${esc(r.dns_records.join('\n'))}
+

${T('Przykład nginx dla autodiscover.' + r.domain, 'nginx example for autodiscover.' + r.domain)}

${esc(r.nginx)}
` : ''; + return `

@${esc(r.domain)}

${rows}
${T('Wynik', 'Result')}${T('Sprawdzenie', 'Check')}${T('Szczegóły', 'Details')}
${advice}`; + } + + PAGES.autodiscover = (v) => settingsPage(v, 'PROTOCOL', 'Autodiscover', + T('Automatyczna konfiguracja kont na telefonach: domeny, wymagane rekordy DNS i diagnostyka endpointów.', 'Automatic account setup on phones: served domains, required DNS records and endpoint diagnostics.'), + (s) => section(s, T('Ustawienia', 'Settings'), T('Telefon szuka Autodiscover pod domeną adresu e-mail (np. user@example.com → https://example.com/autodiscover/…, https://autodiscover.example.com/autodiscover/… lub rekord SRV), a nie pod nazwą serwera.', 'Phones look for Autodiscover on the domain of the e-mail address (e.g. user@example.com → https://example.com/autodiscover/…, https://autodiscover.example.com/autodiscover/… or an SRV record), not on the server name.'), [ + ['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.') }], + ], `
`), + { bind: (form) => { + const doms = (S.settings.autodiscover.domains || []).filter(Boolean).map((e) => e.trim().split(/[\s=]+/)[0].toLowerCase().replace(/\.$/, '')); + const out = $('#diag-out', form); + $('#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) => { + const b = e.target; + b.disabled = true; out.innerHTML = ''; + for (const d of doms) { + const holder = document.createElement('div'); + holder.innerHTML = `
${T('Sprawdzam', 'Checking')} ${esc(d)}…
`; + out.appendChild(holder); + try { holder.innerHTML = diagHtml(await api('POST', '/api/autodiscover/check', { domain: d })); } + catch (err) { holder.innerHTML = `
${esc(d)}: ${esc(err.message)}
`; } + } + b.disabled = false; + }); + } }); + PAGES.policies = async (v) => { let sel = 0; await settingsPage(v, 'PROVISIONING', T('Polityki urządzeń', 'Device policies'), T('Polityki bezpieczeństwa przekazywane urządzeniom (odpowiednik policies.ini). Zmiana polityki wymusza ponowny provisioning urządzeń.', 'Security policies pushed to devices (policies.ini equivalent). Changing a policy forces devices to re-provision.'), (s) => { diff --git a/web/style.css b/web/style.css index 34ded3b..30bea5d 100644 --- a/web/style.css +++ b/web/style.css @@ -361,3 +361,10 @@ select.quiet { background: var(--panel); color: var(--text); } .chip:hover { border-color: var(--accent); } .chip .signal { margin: 0; width: 6px; height: 6px; } td.wrap { white-space: normal; max-width: 420px; } + +.pill.warn { border-color: color-mix(in srgb, var(--accent) 55%, transparent); color: var(--accent); } +.diag-bar { display: flex; align-items: center; gap: 12px; margin-top: 14px; flex-wrap: wrap; } +#diag-out { margin-top: 10px; } +.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; }