v0.1.4
No files matched your search
@@ -5,3 +5,4 @@ dist/
|
|||||||
*.db
|
*.db
|
||||||
*.db-wal
|
*.db-wal
|
||||||
*.db-shm
|
*.db-shm
|
||||||
|
/*.*
|
||||||
@@ -1,5 +1,25 @@
|
|||||||
# Changelog
|
# 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
|
## 0.1.1 - 2026-11-05
|
||||||
|
|
||||||
- Fix: devices migrated from Z-Push looping on unknown collection ids (`collection ... not found`) - such ids are
|
- Fix: devices migrated from Z-Push looping on unknown collection ids (`collection ... not found`) - such ids are
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ version = 4
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "active-push"
|
name = "active-push"
|
||||||
version = "0.1.1"
|
version = "0.1.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "active-push"
|
name = "active-push"
|
||||||
version = "0.1.1"
|
version = "0.1.4"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.85"
|
rust-version = "1.85"
|
||||||
description = "ActivePush - Exchange ActiveSync server (Z-Push rewrite) with built-in administration panel"
|
description = "ActivePush - Exchange ActiveSync server (Z-Push rewrite) with built-in administration panel"
|
||||||
|
|||||||
@@ -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
|
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
|
CalDAV with `TZID` and a generated `VTIMEZONE`; single events in UTC (unless the original used `TZID`, in which
|
||||||
case the format is kept).
|
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).
|
||||||
@@ -76,16 +76,41 @@ the form (also unsaved ones) and an optional test login; the password is never s
|
|||||||

|

|
||||||
|
|
||||||
Login format and default domain, e-mail address template, protocol version, synchronisation limits, push
|
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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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://<domain>/autodiscover/autodiscover.xml`,
|
||||||
|
`https://autodiscover.<domain>/autodiscover/autodiscover.xml` or the DNS record `_autodiscover._tcp.<domain>`.
|
||||||
|
|
||||||
|
- **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.<domain>`).
|
||||||
|
- **Run diagnostics** (saved domains only) checks, per domain: A/AAAA records of the domain, `autodiscover.<domain>`
|
||||||
|
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
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Provisioning switch, loose provisioning, named policies with every `policies.ini` option and per-mailbox
|
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.
|
assignments (`login=policy`, one per line). Changing a policy makes devices accept it again.
|
||||||
|
|
||||||
## 08 Security
|
## 09 Security
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -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,
|
panel, allowed/blocked mailboxes and device types, device approval, panel session lifetime, Prometheus token,
|
||||||
administrator password change and signing out other sessions.
|
administrator password change and signing out other sessions.
|
||||||
|
|
||||||
## 09 Logs and audit
|
## 10 Logs and audit
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
CLI commands must run as the service user so database files keep the right owner:
|
||||||
`sudo -u active-push active-push ...`.
|
`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://<domain>/autodiscover/autodiscover.xml`, `https://autodiscover.<domain>/autodiscover/autodiscover.xml`
|
||||||
|
and the DNS SRV record `_autodiscover._tcp.<domain>`; 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.<domain>`, 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.
|
||||||
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 247 KiB |
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 196 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 166 KiB |
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 145 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 107 KiB |
@@ -219,6 +219,7 @@ pub fn router(app: Arc<App>) -> Router {
|
|||||||
.route("/api/users", get(users))
|
.route("/api/users", get(users))
|
||||||
.route("/api/settings", get(get_settings).put(put_settings))
|
.route("/api/settings", get(get_settings).put(put_settings))
|
||||||
.route("/api/test/{what}", post(test_backend))
|
.route("/api/test/{what}", post(test_backend))
|
||||||
|
.route("/api/autodiscover/check", post(autodiscover_check))
|
||||||
.route("/api/logs", get(logs))
|
.route("/api/logs", get(logs))
|
||||||
.route("/api/audit", get(audit))
|
.route("/api/audit", get(audit))
|
||||||
.route("/api/lockouts", get(lockouts))
|
.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<Arc<App>>, Json(req): Json<AdCheckReq>) -> 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)]
|
#[derive(Deserialize)]
|
||||||
struct LogQ {
|
struct LogQ {
|
||||||
level: Option<String>,
|
level: Option<String>,
|
||||||
|
|||||||
@@ -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#"<?xml version="1.0" encoding="utf-8"?><Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/mobilesync/requestschema/2006"><Request><EMailAddress>diagnostics@invalid</EMailAddress><AcceptableResponseSchema>http://schemas.microsoft.com/exchange/autodiscover/mobilesync/responseschema/2006</AcceptableResponseSchema></Request></Autodiscover>"#;
|
||||||
|
|
||||||
|
/// 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::<std::net::IpAddr>().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<String>, detail: impl Into<String>) -> Check {
|
||||||
|
Check {
|
||||||
|
level,
|
||||||
|
title: title.into(),
|
||||||
|
detail: detail.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn host_of(url: &str) -> Option<String> {
|
||||||
|
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<String> = 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<Answer, String> {
|
||||||
|
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<Check>) -> 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<Probe, reqwest::Error> {
|
||||||
|
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::<String>()
|
||||||
|
};
|
||||||
|
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<Check>) -> 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<Check> = 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.<domain>) 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::<Vec<_>>()
|
||||||
|
.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<String> = 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::<Vec<_>>(),
|
||||||
|
"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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,16 +62,80 @@ fn safe_host(h: &str) -> Option<&str> {
|
|||||||
.then_some(h)
|
.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(
|
pub async fn handler(
|
||||||
State(app): State<Arc<App>>,
|
State(app): State<Arc<App>>,
|
||||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||||
method: Method,
|
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::<String>()
|
||||||
|
};
|
||||||
|
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::<u64>().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<App>,
|
||||||
|
peer: SocketAddr,
|
||||||
|
method: Method,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
body: Body,
|
body: Body,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let s = app.settings();
|
let s = app.settings();
|
||||||
let ip = client_ip(peer, &headers, &s).to_string();
|
let ip = client_ip(peer, &headers, &s).to_string();
|
||||||
let started = std::time::Instant::now();
|
|
||||||
app.metrics
|
app.metrics
|
||||||
.c
|
.c
|
||||||
.autodiscover
|
.autodiscover
|
||||||
@@ -101,11 +165,22 @@ pub async fn handler(
|
|||||||
Err(_) => return xml_response(StatusCode::OK, error_xml(600, "Invalid Request")),
|
Err(_) => return xml_response(StatusCode::OK, error_xml(600, "Invalid Request")),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if schema != MOBILESYNC {
|
if !schema.is_empty() && schema != MOBILESYNC {
|
||||||
tracing::info!(ip = %ip, schema = %schema, "autodiscover: unsupported response schema");
|
tracing::info!(ip = %ip, schema = %schema, "autodiscover: unsupported response schema (only MobileSync is served)");
|
||||||
return xml_response(StatusCode::OK, error_xml(600, "Invalid Request"));
|
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
|
// authentication
|
||||||
let creds = basic_auth(&headers, &s);
|
let creds = basic_auth(&headers, &s);
|
||||||
let login = match (&creds, s.autodiscover.require_auth) {
|
let login = match (&creds, s.autodiscover.require_auth) {
|
||||||
@@ -158,8 +233,12 @@ pub async fn handler(
|
|||||||
if email.is_empty() || !email.contains('@') {
|
if email.is_empty() || !email.contains('@') {
|
||||||
email = crate::backend::primary_email(&s, &login);
|
email = crate::backend::primary_email(&s, &login);
|
||||||
}
|
}
|
||||||
let url = if !s.autodiscover.server_url.is_empty() {
|
let configured = email
|
||||||
s.autodiscover.server_url.clone()
|
.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 {
|
} else {
|
||||||
let host = headers
|
let host = headers
|
||||||
.get("x-forwarded-host")
|
.get("x-forwarded-host")
|
||||||
@@ -195,21 +274,5 @@ pub async fn handler(
|
|||||||
xml_escape(&url),
|
xml_escape(&url),
|
||||||
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)
|
xml_response(StatusCode::OK, xml)
|
||||||
}
|
}
|
||||||
@@ -454,6 +454,53 @@ pub struct Autodiscover {
|
|||||||
pub server_url: String,
|
pub server_url: String,
|
||||||
/// Validate credentials against IMAP before answering (recommended).
|
/// Validate credentials against IMAP before answering (recommended).
|
||||||
pub require_auth: bool,
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Autodiscover {
|
||||||
|
/// A list entry is `domain` or `domain <ActiveSync URL>` (whitespace or `=` separated).
|
||||||
|
pub fn split_entry(e: &str) -> (String, Option<String>) {
|
||||||
|
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<String> {
|
||||||
|
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 {
|
impl Default for Autodiscover {
|
||||||
@@ -462,6 +509,7 @@ impl Default for Autodiscover {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
server_url: String::new(),
|
server_url: String::new(),
|
||||||
require_auth: true,
|
require_auth: true,
|
||||||
|
domains: vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -566,6 +614,25 @@ pub const SECRET_FIELDS: &[&str] = &["/smtp/password", "/panel/prometheus_token"
|
|||||||
|
|
||||||
impl Settings {
|
impl Settings {
|
||||||
pub fn validate(&self) -> Result<(), String> {
|
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() {
|
if self.imap.host.trim().is_empty() {
|
||||||
return Err("IMAP host is required".into());
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -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<Rr>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Answer {
|
||||||
|
pub fn nxdomain(&self) -> bool {
|
||||||
|
self.rcode == 3
|
||||||
|
}
|
||||||
|
pub fn ips(&self) -> Vec<IpAddr> {
|
||||||
|
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<String> {
|
||||||
|
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<SocketAddr> {
|
||||||
|
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::<IpAddr>() {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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<String> {
|
||||||
|
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<Answer> {
|
||||||
|
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<Answer> {
|
||||||
|
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.<pointer to example.com @ 12+1+13+1+4 = 31>
|
||||||
|
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))]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
pub mod admin;
|
pub mod admin;
|
||||||
pub mod app;
|
pub mod app;
|
||||||
|
pub mod autodiag;
|
||||||
pub mod autodiscover;
|
pub mod autodiscover;
|
||||||
pub mod backend;
|
pub mod backend;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
|
pub mod dnscheck;
|
||||||
pub mod eas;
|
pub mod eas;
|
||||||
pub mod logging;
|
pub mod logging;
|
||||||
pub mod metrics;
|
pub mod metrics;
|
||||||
@@ -33,9 +35,18 @@ pub fn public_router(app: Arc<app::App>) -> Router {
|
|||||||
.route("/healthz", axum::routing::get(healthz))
|
.route("/healthz", axum::routing::get(healthz))
|
||||||
.route("/", axum::routing::get(index))
|
.route("/", axum::routing::get(index))
|
||||||
.fallback(not_found)
|
.fallback(not_found)
|
||||||
|
.layer(axum::middleware::map_response(mark))
|
||||||
.with_state(app)
|
.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 {
|
async fn index() -> impl axum::response::IntoResponse {
|
||||||
(
|
(
|
||||||
[("Content-Type", "text/plain; charset=utf-8")],
|
[("Content-Type", "text/plain; charset=utf-8")],
|
||||||
|
|||||||
@@ -46,6 +46,15 @@ const path = require('path');
|
|||||||
await page.waitForTimeout(wait);
|
await page.waitForTimeout(wait);
|
||||||
await shot(id === 'users' ? 'mailboxes' : id, full);
|
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.goto(`${base}/#devices`);
|
||||||
await page.waitForTimeout(2000);
|
await page.waitForTimeout(2000);
|
||||||
const chips = await page.$$('.chip[data-pid]');
|
const chips = await page.$$('.chip[data-pid]');
|
||||||
|
|||||||
@@ -203,6 +203,7 @@
|
|||||||
['users', T('Skrzynki', 'Mailboxes')],
|
['users', T('Skrzynki', 'Mailboxes')],
|
||||||
['backends', T('Backendy', 'Backends')],
|
['backends', T('Backendy', 'Backends')],
|
||||||
['activesync', 'ActiveSync'],
|
['activesync', 'ActiveSync'],
|
||||||
|
['autodiscover', 'Autodiscover'],
|
||||||
['policies', T('Polityki', 'Policies')],
|
['policies', T('Polityki', 'Policies')],
|
||||||
['security', T('Bezpieczeństwo', 'Security')],
|
['security', T('Bezpieczeństwo', 'Security')],
|
||||||
['logs', T('Logi i audyt', 'Logs & audit')],
|
['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'],
|
['carddav.include_regex', 'Include regex', 'text'], ['carddav.exclude_regex', 'Exclude regex', 'text'], ['carddav.timeout_secs', 'Timeout (s)', 'number'],
|
||||||
], testBlock('carddav')), { bind: bindTests });
|
], 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'), '', [
|
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.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.') }],
|
['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.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'],
|
['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'), '', [
|
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.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'],
|
['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'],
|
['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) => `<tr><td><span class="pill ${LEVEL_CLASS[c.level] || 'muted'}">${esc(LEVEL_LABEL[c.level] || c.level)}</span></td><td class="wrap mono-cell">${esc(c.title)}</td><td class="wrap">${esc(c.detail)}</td></tr>`).join('');
|
||||||
|
const verdictFail = r.checks[0] && r.checks[0].level === 'fail';
|
||||||
|
const advice = verdictFail ? `<h3 class="sub-h">${T('Zalecane rekordy DNS (wystarczy jeden z nich)', 'Recommended DNS records (one of them is enough)')}</h3><pre class="code">${esc(r.dns_records.join('\n'))}</pre>
|
||||||
|
<h3 class="sub-h">${T('Przykład nginx dla autodiscover.' + r.domain, 'nginx example for autodiscover.' + r.domain)}</h3><pre class="code">${esc(r.nginx)}</pre>` : '';
|
||||||
|
return `<h3 class="sub-h">@${esc(r.domain)}</h3><table><thead><tr><th>${T('Wynik', 'Result')}</th><th>${T('Sprawdzenie', 'Check')}</th><th>${T('Szczegóły', 'Details')}</th></tr></thead><tbody>${rows}</tbody></table>${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.<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>`),
|
||||||
|
{ 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 = `<div class="result">${T('Sprawdzam', 'Checking')} ${esc(d)}…</div>`;
|
||||||
|
out.appendChild(holder);
|
||||||
|
try { holder.innerHTML = diagHtml(await api('POST', '/api/autodiscover/check', { domain: d })); }
|
||||||
|
catch (err) { holder.innerHTML = `<div class="result bad">${esc(d)}: ${esc(err.message)}</div>`; }
|
||||||
|
}
|
||||||
|
b.disabled = false;
|
||||||
|
});
|
||||||
|
} });
|
||||||
|
|
||||||
PAGES.policies = async (v) => {
|
PAGES.policies = async (v) => {
|
||||||
let sel = 0;
|
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) => {
|
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) => {
|
||||||
|
|||||||
@@ -361,3 +361,10 @@ select.quiet { background: var(--panel); color: var(--text); }
|
|||||||
.chip:hover { border-color: var(--accent); }
|
.chip:hover { border-color: var(--accent); }
|
||||||
.chip .signal { margin: 0; width: 6px; height: 6px; }
|
.chip .signal { margin: 0; width: 6px; height: 6px; }
|
||||||
td.wrap { white-space: normal; max-width: 420px; }
|
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; }
|
||||||