This commit is contained in:
Mateusz Gruszczyński committed 2026-11-08 08:43:14 +01:00
1 parent 19dfc51360
commit b6e8ddb9c7
31 files changed
+1080 -32

No files matched your search

+1
View File
@@ -5,3 +5,4 @@ dist/
*.db *.db
*.db-wal *.db-wal
*.db-shm *.db-shm
/*.*
+20
View File
@@ -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
Generated
+1 -1
View File
@@ -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 -1
View File
@@ -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"
+3
View File
@@ -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).
+29 -4
View File
@@ -76,16 +76,41 @@ the form (also unsaved ones) and an optional test login; the password is never s
![ActiveSync](images/activesync.png) ![ActiveSync](images/activesync.png)
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
![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://<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
![Policies](images/policies.png) ![Policies](images/policies.png)
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
![Security](images/security.png) ![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, 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
![Logs](images/logs.png) ![Logs](images/logs.png)
+17
View File
@@ -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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 107 KiB

+22
View File
@@ -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>,
+425
View File
@@ -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"));
}
}
+84 -21
View File
@@ -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)
} }
+99
View File
@@ -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]
+311
View File
@@ -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))]);
}
}
+11
View File
@@ -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")],
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+9
View File
@@ -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]');
+40 -5
View File
@@ -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) => {
+7
View File
@@ -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; }