diff --git a/crates/common/src/config/network.rs b/crates/common/src/config/network.rs index ad647cd..39b9a19 100644 --- a/crates/common/src/config/network.rs +++ b/crates/common/src/config/network.rs @@ -47,6 +47,9 @@ pub struct Network { #[derive(Clone)] pub struct NetworkInfo { pub pacc: Pacc, + /// inbuxa: the same document without IMAP, POP3, SMTP and ManageSieve, + /// served while legacy protocols are off (legacy-protocols LP-7). + pub pacc_jmap_only: Pacc, pub mxs: Vec, pub services: VecMap, } @@ -320,11 +323,26 @@ impl Network { } } - let (prefix, suffix) = serde_json::to_string(&pacc) - .unwrap_or_default() - .rsplit_once(SPLIT_HERE) - .map(|(prefix, suffix)| (prefix.to_string(), suffix.to_string())) - .unwrap(); + let split = |pacc: &Configuration| { + serde_json::to_string(pacc) + .unwrap_or_default() + .rsplit_once(SPLIT_HERE) + .map(|(prefix, suffix)| Pacc { + prefix: prefix.to_string(), + suffix: suffix.to_string(), + }) + .unwrap() + }; + // inbuxa: legacy-protocols LP-7 + let pacc_jmap_only = { + let mut pacc = pacc.clone(); + pacc.protocols.imap = None; + pacc.protocols.pop3 = None; + pacc.protocols.smtp = None; + pacc.protocols.managesieve = None; + split(&pacc) + }; + let pacc = split(&pacc); let mut network = Network { node_id: bp.node_id() as u64, server_name: default_hostname.to_string(), @@ -339,7 +357,8 @@ impl Network { info: NetworkInfo { mxs: system.mail_exchangers.into_iter().collect(), services: system.services, - pacc: Pacc { prefix, suffix }, + pacc, + pacc_jmap_only, }, }; diff --git a/crates/common/src/network/autoconfig/autodiscover.rs b/crates/common/src/network/autoconfig/autodiscover.rs index c548a48..5818ba8 100644 --- a/crates/common/src/network/autoconfig/autodiscover.rs +++ b/crates/common/src/network/autoconfig/autodiscover.rs @@ -2,9 +2,11 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ -use crate::{Server, manager::application::Resource}; +use crate::{Server, manager::application::Resource, network::legacy::is_legacy_service}; use quick_xml::Reader; use quick_xml::XmlVersion; use quick_xml::events::Event; @@ -55,7 +57,12 @@ impl Server { let _ = writeln!(&mut config, "\t\t"); let _ = writeln!(&mut config, "\t\t\temail"); let _ = writeln!(&mut config, "\t\t\tsettings"); + // inbuxa: legacy-protocols LP-7 + let legacy_off = self.legacy_protocols_off().await?; for (protocol, service) in &self.core.network.info.services { + if legacy_off && is_legacy_service(protocol) { + continue; + } let (protocol, ports) = match protocol { ServiceProtocol::Imap => ("IMAP", [143, 993]), ServiceProtocol::Pop3 => ("POP3", [110, 995]), diff --git a/crates/common/src/network/autoconfig/legacy_autoconfig.rs b/crates/common/src/network/autoconfig/legacy_autoconfig.rs index 2f2e2cb..3bb9c0f 100644 --- a/crates/common/src/network/autoconfig/legacy_autoconfig.rs +++ b/crates/common/src/network/autoconfig/legacy_autoconfig.rs @@ -2,9 +2,11 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ -use crate::{Server, manager::application::Resource}; +use crate::{Server, manager::application::Resource, network::legacy::is_legacy_service}; use registry::schema::enums::ServiceProtocol; use std::fmt::Write; use utils::url_params::UrlParams; @@ -28,6 +30,9 @@ impl Server { ("%EMAILADDRESS%", default_host.as_str()) }; + // inbuxa: legacy-protocols LP-7 + let legacy_off = self.legacy_protocols_off().await?; + // Build XML response let mut config = String::with_capacity(1024); config.push_str("\n"); @@ -40,6 +45,9 @@ impl Server { "\t\t{domain}" ); for (protocol, service) in &self.core.network.info.services { + if legacy_off && is_legacy_service(protocol) { + continue; + } let (protocol, tag, ports) = match protocol { ServiceProtocol::Smtp => ("smtp", "outgoingServer", [587, 465]), ServiceProtocol::Imap => ("imap", "incomingServer", [143, 993]), diff --git a/crates/common/src/network/dns/records.rs b/crates/common/src/network/dns/records.rs index 221ae15..f371522 100644 --- a/crates/common/src/network/dns/records.rs +++ b/crates/common/src/network/dns/records.rs @@ -2,9 +2,15 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ -use crate::{Server, config::network::Pacc, network::dkim::generate_dkim_dns_record}; +use crate::{ + Server, + config::network::Pacc, + network::{dkim::generate_dkim_dns_record, legacy::is_legacy_service}, +}; use ahash::{AHashMap, AHashSet}; use base64::{Engine, engine::general_purpose}; use dns_update::{ @@ -33,6 +39,8 @@ impl Server { let mut records = Vec::new(); let network = &self.core.network; let default_host = network.server_name.as_str(); + // inbuxa: legacy-protocols LP-7 + let legacy_off = self.legacy_protocols_off().await?; let domain_name = domain.name.as_str(); let domain_name_suffix = format!(".{domain_name}"); @@ -193,6 +201,25 @@ impl Server { ServiceProtocol::Smtp => [("submission", 587), ("submissions", 465)], }; + // inbuxa: legacy-protocols LP-7. While they are off, every + // name says "not offered" -- target "." (RFC 6186 section + // 3.4) -- rather than vanishing, so a client that looks + // is told, and an old record left in the zone is replaced. + if legacy_off && is_legacy_service(protocol) { + for (service_name, _) in services { + records.push(NamedDnsRecord { + name: format!("_{service_name}._tcp.{domain_name}."), + record: DnsRecord::SRV(SRVRecord { + target: ".".to_string(), + priority: 0, + weight: 0, + port: 0, + }), + }); + } + continue; + } + for (is_tls, (service_name, port)) in services.into_iter().enumerate() { if is_tls == 1 || service.cleartext { records.push(NamedDnsRecord { @@ -277,6 +304,14 @@ impl Server { for (protocol, service) in &network.info.services { let hostname = service.hostname.as_deref().unwrap_or(default_host); if hostname.ends_with(&domain_name_suffix) || hostname == domain_name { + // inbuxa: legacy-protocols LP-7. No TLS pin for a port + // the switch has closed. Submission's port stays open + // (the SMTP lock), so its record stays. + if legacy_off + && matches!(protocol, ServiceProtocol::Imap | ServiceProtocol::Pop3) + { + continue; + } let port = match protocol { ServiceProtocol::Imap => 993, ServiceProtocol::Pop3 => 995, @@ -382,6 +417,12 @@ impl Server { } pub async fn get_pacc_for_domain(&self, domain_name: &str) -> trc::Result { + // inbuxa: legacy-protocols LP-7 + let pacc = if self.legacy_protocols_off().await? { + &self.core.network.info.pacc_jmap_only + } else { + &self.core.network.info.pacc + }; self.get_directory_for_domain(domain_name) .await .caused_by(trc::location!()) @@ -390,15 +431,9 @@ impl Server { .and_then(|directory| { directory .oidc_discovery_document() - .map(|doc| self.core.network.info.pacc.build(&doc.url)) - }) - .unwrap_or_else(|| { - self.core - .network - .info - .pacc - .build(&self.core.network.http.url_https) + .map(|doc| pacc.build(&doc.url)) }) + .unwrap_or_else(|| pacc.build(&self.core.network.http.url_https)) }) } } diff --git a/crates/common/src/network/legacy.rs b/crates/common/src/network/legacy.rs index 441c1d8..fbf49ed 100644 --- a/crates/common/src/network/legacy.rs +++ b/crates/common/src/network/legacy.rs @@ -21,6 +21,10 @@ //! a legacy protocol is refused before any password is looked at, so a //! listener that exists by mistake still lets nobody in. //! +//! And nothing advertises what is closed (LP-7): client configuration and +//! the suggested DNS records leave the legacy services out, or mark them as +//! not offered, while the switch is off. +//! //! Nothing here touches the host's firewall, NAT port-forwards or any proxy //! (LP-20). The server stops answering; what still routes the port is the //! operator's to reconcile. @@ -31,6 +35,7 @@ use inbuxa_features::security::{ listeners, protocol_policy::{self, ProtocolPolicy, SavedListener}, }; +use registry::schema::enums::ServiceProtocol; use registry::types::{error::Error, id::ObjectId}; use store::registry::bootstrap::Bootstrap; @@ -302,6 +307,27 @@ impl Server { } } +/// The services mail apps sign in to, which the switch turns off: nothing may +/// offer them while it is (LP-7). SMTP here is submission -- mail apps +/// sending -- since inbound mail is never a configured service. +pub fn is_legacy_service(protocol: &ServiceProtocol) -> bool { + matches!( + protocol, + ServiceProtocol::Imap + | ServiceProtocol::Pop3 + | ServiceProtocol::Smtp + | ServiceProtocol::Managesieve + ) +} + +impl Server { + /// Whether the server-wide switch is off, for the answers that must stop + /// offering legacy services (LP-7). Read per answer, as sign-in reads it. + pub async fn legacy_protocols_off(&self) -> trc::Result { + Ok(self.protocol_policy().await?.legacy_protocols.is_disabled()) + } +} + #[cfg(test)] mod tests { use super::*; @@ -349,6 +375,26 @@ mod tests { assert_eq!(err.value_as_str(trc::Key::AccountName), None); } + #[test] + fn only_the_services_mail_apps_sign_in_to_are_legacy() { + for protocol in [ + ServiceProtocol::Imap, + ServiceProtocol::Pop3, + ServiceProtocol::Smtp, + ServiceProtocol::Managesieve, + ] { + assert!(is_legacy_service(&protocol), "{protocol:?}"); + } + for protocol in [ + ServiceProtocol::Jmap, + ServiceProtocol::Caldav, + ServiceProtocol::Carddav, + ServiceProtocol::Webdav, + ] { + assert!(!is_legacy_service(&protocol), "{protocol:?}"); + } + } + #[test] fn the_domain_comes_from_the_name_given() { assert_eq!(domain_of(&basic("a@b.test")), Some("b.test".to_string())); diff --git a/tests/e2e/legacy_protocols.py b/tests/e2e/legacy_protocols.py index 7831291..e310bf0 100755 --- a/tests/e2e/legacy_protocols.py +++ b/tests/e2e/legacy_protocols.py @@ -17,7 +17,9 @@ submission -- locked open -- is refused with the spec's words, with the right password and with a wrong one, and refusals never add up to a disconnect (LP-11). And that a normal IMAP sign-in works with the switch on, before and after. And that while it is off, no listener the switch would close can be -created, or made by an update (LP-4, test 4). +created, or made by an update (LP-4, test 4), and nothing advertises what is +closed: autoconfig, autodiscover and PACC offer no IMAP, POP3 or submission, +and the suggested zone marks their SRV names not offered (LP-7, test 5). Passwords are generated into files under target/e2e and never printed. Everything is removed afterwards unless KEEP=1. @@ -189,6 +191,40 @@ def smtp_auths(port, user, passwords): return replies +def advertised(admin, admin_pw): + """What each client-configuration answer and the suggested zone offer.""" + with urllib.request.urlopen(f"{HTTP}/mail/config-v1.1.xml?emailaddress=a@legacy.test", + timeout=30) as resp: + autoconfig = resp.read().decode() + body = ('' + 'a@legacy.testhttp://' + 'schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a' + '').encode() + req = urllib.request.Request(f"{HTTP}/autodiscover/autodiscover.xml", data=body, method="POST") + req.add_header("Content-Type", "text/xml") + with urllib.request.urlopen(req, timeout=30) as resp: + autodiscover = resp.read().decode() + with urllib.request.urlopen(f"{HTTP}/.well-known/user-agent-configuration.json", + timeout=30) as resp: + pacc = json.load(resp).get("protocols", {}) + got = one(admin, admin_pw, "x:Domain/get", {"ids": None, "properties": ["name", "dnsZoneFile"]}) + zone = next((d.get("dnsZoneFile") or "" for d in got[1].get("list", []) + if d.get("name") == "legacy.test"), "") + srv = {} + for line in zone.splitlines(): + fields = line.split() + if "SRV" in fields and fields[0].startswith("_"): + srv[fields[0].split(".")[0] + "." + fields[0].split(".")[1]] = fields[-1] + return { + "autoconfig": {t for t in ("imap", "pop3", "smtp") if f'type="{t}"' in autoconfig}, + "autodiscover": {t for t in ("IMAP", "POP3", "SMTP") if f"{t}" in autodiscover}, + "pacc": {t for t in ("imap", "pop3", "smtp", "managesieve") if t in pacc}, + "jmap": "jmap" in pacc, + "srv": srv, + } + + def settle(port, want, tries=30): """Wait for a port to reach the wanted state, so the check is not a race.""" for _ in range(tries): @@ -243,6 +279,15 @@ def main(): check(accepts(PORTS["submissions"]), "submission accepts before the switch") check(accepts(PORTS["smtp"]), "inbound SMTP accepts before the switch") + # What is advertised with the switch on -- the control for LP-7. + before = advertised(admin, admin_pw) + print(" advertised before:", {k: sorted(v) if isinstance(v, set) else v + for k, v in before.items() if k != "srv"}) + check(before["autoconfig"] and before["autodiscover"], + "autoconfig and autodiscover offer mail apps a server with the switch on") + check(before["srv"].get("_imaps._tcp", ".") != ".", + "the suggested zone offers IMAP with the switch on") + # A normal sign-in works with the switch on -- the control for LP-6. check(imap_login(PORTS["imap"], admin, admin_pw).startswith("OK"), "IMAP sign-in works with the switch on") @@ -295,6 +340,19 @@ def main(): if not all(r == SMTP_REFUSAL for r in replies): print(" replies:", replies) + # Nothing advertises what is closed (LP-7, test 5). + during = advertised(admin, admin_pw) + check(not during["autoconfig"], "autoconfig offers no IMAP, POP3 or submission (LP-7)") + check(not during["autodiscover"], "autodiscover offers no IMAP, POP3 or submission (LP-7)") + check(not during["pacc"] and during["jmap"], "PACC offers JMAP and nothing legacy (LP-7)") + names = ("_imap._tcp", "_imaps._tcp", "_pop3._tcp", "_pop3s._tcp", + "_submission._tcp", "_submissions._tcp") + offered = {n: t for n, t in during["srv"].items() if n in names and t != "."} + check(not offered and "_imaps._tcp" in during["srv"], + "the suggested zone marks the legacy SRV names not offered, target . (LP-7)") + if offered or "_imaps._tcp" not in during["srv"]: + print(" srv:", during["srv"]) + # No listener the switch would close can be added while it is off (LP-4, # test 4), and the refusal names the policy. res = one(admin, admin_pw, "x:NetworkListener/set", {"create": {"m": { @@ -341,6 +399,10 @@ def main(): check(policy["legacyProtocols"] == "enabled", "switch reads back enabled") check(not policy["savedListeners"], "savedListeners is empty again (LP-5)") + after = advertised(admin, admin_pw) + check(after["autoconfig"] == before["autoconfig"] and after["srv"] == before["srv"], + "autoconfig and the suggested zone offer them again once back on") + # And sign-in works again, with no restart. check(imap_login(PORTS["imap"], admin, admin_pw).startswith("OK"), "IMAP sign-in works again once the switch is back on")