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)]
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<MailExchanger>,
pub services: VecMap<ServiceProtocol, Service>,
}
@@ -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,
},
};
@@ -2,9 +2,11 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* 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<Account>");
let _ = writeln!(&mut config, "\t\t\t<AccountType>email</AccountType>");
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 {
if legacy_off && is_legacy_service(protocol) {
continue;
}
let (protocol, ports) = match protocol {
ServiceProtocol::Imap => ("IMAP", [143, 993]),
ServiceProtocol::Pop3 => ("POP3", [110, 995]),
@@ -2,9 +2,11 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* 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("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
@@ -40,6 +45,9 @@ impl Server {
"\t\t<displayShortName>{domain}</displayShortName>"
);
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]),
+44 -9
View File
@@ -2,9 +2,15 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* 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<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)
.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))
})
}
}
+46
View File
@@ -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<bool> {
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("[email protected]")), Some("b.test".to_string()));