Nothing advertises the legacy protocols while they are off (LP-7)

While the switch is off, the answers that tell a mail app where to connect
stop offering what the switch closed, so a new phone or desktop app is not
sent to a port that is shut or a sign-in that will be refused:

- Thunderbird-style autoconfig (/mail/config-v1.1.xml and its other
  paths) and Outlook autodiscover leave out IMAP, POP3 and SMTP
  submission.
- PACC (/.well-known/user-agent-configuration.json) offers JMAP, CalDAV,
  CardDAV and WebDAV, and no IMAP, POP3, SMTP or ManageSieve. The document
  is rendered once per configuration load, so the JMAP-only version is
  rendered beside it and chosen per request; the _ua-auto-config digest in
  the suggested zone follows, since it hashes the same document.
- The suggested zone publishes _imap, _imaps, _pop3, _pop3s, _submission
  and _submissions with target "." -- "not offered", RFC 6186 section 3.4 --
  the spec's decision, rather than dropping them: a client that looks is
  told, and an automatically managed zone replaces the old records instead
  of leaving them behind.
- It also drops the TLSA records for ports 993 and 995. A TLS pin for a
  port the switch has closed advertises a service that is not there.
  Submission's 465 keeps its record: the SMTP lock keeps that port open.

The switch is read per answer, as sign-in reads it, so every node agrees
the moment it turns. Inbound mail, MX records and the JMAP, CalDAV and
CardDAV answers are untouched.

tests/e2e/legacy_protocols.py checks all four on a running server: with the
switch on they offer IMAP, POP3 and SMTP (the control); while it is off
they offer none of them and every legacy SRV name has target "."; and once
it is back on, autoconfig and the zone read as they did before. All checks
pass.
This commit is contained in:
2026-09-21 10:41:20 -07:00
parent 7dfe4c8e70
commit 4b585905d7
6 changed files with 195 additions and 18 deletions
+25 -6
View File
@@ -47,6 +47,9 @@ pub struct Network {
#[derive(Clone)] #[derive(Clone)]
pub struct NetworkInfo { pub struct NetworkInfo {
pub pacc: Pacc, 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<MailExchanger>, pub mxs: Vec<MailExchanger>,
pub services: VecMap<ServiceProtocol, Service>, pub services: VecMap<ServiceProtocol, Service>,
} }
@@ -320,11 +323,26 @@ impl Network {
} }
} }
let (prefix, suffix) = serde_json::to_string(&pacc) let split = |pacc: &Configuration| {
.unwrap_or_default() serde_json::to_string(pacc)
.rsplit_once(SPLIT_HERE) .unwrap_or_default()
.map(|(prefix, suffix)| (prefix.to_string(), suffix.to_string())) .rsplit_once(SPLIT_HERE)
.unwrap(); .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 { let mut network = Network {
node_id: bp.node_id() as u64, node_id: bp.node_id() as u64,
server_name: default_hostname.to_string(), server_name: default_hostname.to_string(),
@@ -339,7 +357,8 @@ impl Network {
info: NetworkInfo { info: NetworkInfo {
mxs: system.mail_exchangers.into_iter().collect(), mxs: system.mail_exchangers.into_iter().collect(),
services: system.services, services: system.services,
pacc: Pacc { prefix, suffix }, pacc,
pacc_jmap_only,
}, },
}; };
@@ -2,9 +2,11 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * 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::Reader;
use quick_xml::XmlVersion; use quick_xml::XmlVersion;
use quick_xml::events::Event; use quick_xml::events::Event;
@@ -55,7 +57,12 @@ impl Server {
let _ = writeln!(&mut config, "\t\t<Account>"); let _ = writeln!(&mut config, "\t\t<Account>");
let _ = writeln!(&mut config, "\t\t\t<AccountType>email</AccountType>"); let _ = writeln!(&mut config, "\t\t\t<AccountType>email</AccountType>");
let _ = writeln!(&mut config, "\t\t\t<Action>settings</Action>"); let _ = writeln!(&mut config, "\t\t\t<Action>settings</Action>");
// inbuxa: legacy-protocols LP-7
let legacy_off = self.legacy_protocols_off().await?;
for (protocol, service) in &self.core.network.info.services { for (protocol, service) in &self.core.network.info.services {
if legacy_off && is_legacy_service(protocol) {
continue;
}
let (protocol, ports) = match protocol { let (protocol, ports) = match protocol {
ServiceProtocol::Imap => ("IMAP", [143, 993]), ServiceProtocol::Imap => ("IMAP", [143, 993]),
ServiceProtocol::Pop3 => ("POP3", [110, 995]), ServiceProtocol::Pop3 => ("POP3", [110, 995]),
@@ -2,9 +2,11 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * 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 registry::schema::enums::ServiceProtocol;
use std::fmt::Write; use std::fmt::Write;
use utils::url_params::UrlParams; use utils::url_params::UrlParams;
@@ -28,6 +30,9 @@ impl Server {
("%EMAILADDRESS%", default_host.as_str()) ("%EMAILADDRESS%", default_host.as_str())
}; };
// inbuxa: legacy-protocols LP-7
let legacy_off = self.legacy_protocols_off().await?;
// Build XML response // Build XML response
let mut config = String::with_capacity(1024); let mut config = String::with_capacity(1024);
config.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"); config.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
@@ -40,6 +45,9 @@ impl Server {
"\t\t<displayShortName>{domain}</displayShortName>" "\t\t<displayShortName>{domain}</displayShortName>"
); );
for (protocol, service) in &self.core.network.info.services { for (protocol, service) in &self.core.network.info.services {
if legacy_off && is_legacy_service(protocol) {
continue;
}
let (protocol, tag, ports) = match protocol { let (protocol, tag, ports) = match protocol {
ServiceProtocol::Smtp => ("smtp", "outgoingServer", [587, 465]), ServiceProtocol::Smtp => ("smtp", "outgoingServer", [587, 465]),
ServiceProtocol::Imap => ("imap", "incomingServer", [143, 993]), ServiceProtocol::Imap => ("imap", "incomingServer", [143, 993]),
+44 -9
View File
@@ -2,9 +2,15 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * 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 ahash::{AHashMap, AHashSet};
use base64::{Engine, engine::general_purpose}; use base64::{Engine, engine::general_purpose};
use dns_update::{ use dns_update::{
@@ -33,6 +39,8 @@ impl Server {
let mut records = Vec::new(); let mut records = Vec::new();
let network = &self.core.network; let network = &self.core.network;
let default_host = network.server_name.as_str(); 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 = domain.name.as_str();
let domain_name_suffix = format!(".{domain_name}"); let domain_name_suffix = format!(".{domain_name}");
@@ -193,6 +201,25 @@ impl Server {
ServiceProtocol::Smtp => [("submission", 587), ("submissions", 465)], 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() { for (is_tls, (service_name, port)) in services.into_iter().enumerate() {
if is_tls == 1 || service.cleartext { if is_tls == 1 || service.cleartext {
records.push(NamedDnsRecord { records.push(NamedDnsRecord {
@@ -277,6 +304,14 @@ impl Server {
for (protocol, service) in &network.info.services { for (protocol, service) in &network.info.services {
let hostname = service.hostname.as_deref().unwrap_or(default_host); let hostname = service.hostname.as_deref().unwrap_or(default_host);
if hostname.ends_with(&domain_name_suffix) || hostname == domain_name { 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 { let port = match protocol {
ServiceProtocol::Imap => 993, ServiceProtocol::Imap => 993,
ServiceProtocol::Pop3 => 995, ServiceProtocol::Pop3 => 995,
@@ -382,6 +417,12 @@ impl Server {
} }
pub async fn get_pacc_for_domain(&self, domain_name: &str) -> trc::Result<String> { pub async fn get_pacc_for_domain(&self, domain_name: &str) -> trc::Result<String> {
// 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) self.get_directory_for_domain(domain_name)
.await .await
.caused_by(trc::location!()) .caused_by(trc::location!())
@@ -390,15 +431,9 @@ impl Server {
.and_then(|directory| { .and_then(|directory| {
directory directory
.oidc_discovery_document() .oidc_discovery_document()
.map(|doc| self.core.network.info.pacc.build(&doc.url)) .map(|doc| pacc.build(&doc.url))
})
.unwrap_or_else(|| {
self.core
.network
.info
.pacc
.build(&self.core.network.http.url_https)
}) })
.unwrap_or_else(|| pacc.build(&self.core.network.http.url_https))
}) })
} }
} }
+46
View File
@@ -21,6 +21,10 @@
//! a legacy protocol is refused before any password is looked at, so a //! a legacy protocol is refused before any password is looked at, so a
//! listener that exists by mistake still lets nobody in. //! 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 //! 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 //! (LP-20). The server stops answering; what still routes the port is the
//! operator's to reconcile. //! operator's to reconcile.
@@ -31,6 +35,7 @@ use inbuxa_features::security::{
listeners, listeners,
protocol_policy::{self, ProtocolPolicy, SavedListener}, protocol_policy::{self, ProtocolPolicy, SavedListener},
}; };
use registry::schema::enums::ServiceProtocol;
use registry::types::{error::Error, id::ObjectId}; use registry::types::{error::Error, id::ObjectId};
use store::registry::bootstrap::Bootstrap; 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<bool> {
Ok(self.protocol_policy().await?.legacy_protocols.is_disabled())
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -349,6 +375,26 @@ mod tests {
assert_eq!(err.value_as_str(trc::Key::AccountName), None); 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] #[test]
fn the_domain_comes_from_the_name_given() { fn the_domain_comes_from_the_name_given() {
assert_eq!(domain_of(&basic("[email protected]")), Some("b.test".to_string())); assert_eq!(domain_of(&basic("[email protected]")), Some("b.test".to_string()));
+63 -1
View File
@@ -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 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 (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 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. Passwords are generated into files under target/e2e and never printed.
Everything is removed afterwards unless KEEP=1. Everything is removed afterwards unless KEEP=1.
@@ -189,6 +191,40 @@ def smtp_auths(port, user, passwords):
return replies return replies
def advertised(admin, admin_pw):
"""What each client-configuration answer and the suggested zone offer."""
with urllib.request.urlopen(f"{HTTP}/mail/[email protected]",
timeout=30) as resp:
autoconfig = resp.read().decode()
body = ('<?xml version="1.0" encoding="utf-8"?><Autodiscover xmlns="http://schemas.'
'microsoft.com/exchange/autodiscover/outlook/requestschema/2006"><Request>'
'<EMailAddress>[email protected]</EMailAddress><AcceptableResponseSchema>http://'
'schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a'
'</AcceptableResponseSchema></Request></Autodiscover>').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"<Type>{t}</Type>" 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): def settle(port, want, tries=30):
"""Wait for a port to reach the wanted state, so the check is not a race.""" """Wait for a port to reach the wanted state, so the check is not a race."""
for _ in range(tries): for _ in range(tries):
@@ -243,6 +279,15 @@ def main():
check(accepts(PORTS["submissions"]), "submission accepts before the switch") check(accepts(PORTS["submissions"]), "submission accepts before the switch")
check(accepts(PORTS["smtp"]), "inbound SMTP 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. # A normal sign-in works with the switch on -- the control for LP-6.
check(imap_login(PORTS["imap"], admin, admin_pw).startswith("OK"), check(imap_login(PORTS["imap"], admin, admin_pw).startswith("OK"),
"IMAP sign-in works with the switch on") "IMAP sign-in works with the switch on")
@@ -295,6 +340,19 @@ def main():
if not all(r == SMTP_REFUSAL for r in replies): if not all(r == SMTP_REFUSAL for r in replies):
print(" replies:", 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, # No listener the switch would close can be added while it is off (LP-4,
# test 4), and the refusal names the policy. # test 4), and the refusal names the policy.
res = one(admin, admin_pw, "x:NetworkListener/set", {"create": {"m": { 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(policy["legacyProtocols"] == "enabled", "switch reads back enabled")
check(not policy["savedListeners"], "savedListeners is empty again (LP-5)") 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. # And sign-in works again, with no restart.
check(imap_login(PORTS["imap"], admin, admin_pw).startswith("OK"), check(imap_login(PORTS["imap"], admin, admin_pw).startswith("OK"),
"IMAP sign-in works again once the switch is back on") "IMAP sign-in works again once the switch is back on")