Author SHA1 Message Date
jcoffey-dev 4b585905d7 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.
2026-09-21 10:41:20 -07:00
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()));
+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
(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/[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):
"""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")