Files
inbuxa-server/crates/common/src/network/legacy.rs
T
jcoffey-dev a993f9ab01
ci / fork-checks (pull_request) Successful in 47s
ci / build (pull_request) Successful in 5m4s
Write the brand in lowercase where people see it
The name is inbuxa, lowercase, like the wordmark; INBUXA reads as an
acronym. The admin and webmail already changed. Here that's everything
the server shows people: the brand macro behind the protocol greetings,
the HTTP and SCIM realms, the startup banner and the calendar and contact
PRODID; the first-party OAuth client descriptions; the legacy-protocol
refusals; the default calendar and address book names and the SMTP
greeting default, in the code and the schema served to the admin
(checksum regenerated); startup and shutdown events; the User-Agent;
the sign-in and RSVP pages; the service units; the OpenAPI realm; the
crate descriptions and the README, where it's set in bold.

Identifiers that are uppercase for their own reasons stay: INBUXA_*
settings, SUBSPACE_INBUXA. So do code comments and the AGPL 5(a) notice
lines.

Tests follow: the IMAP ID name, the default collection names, the PRODID
in the iTIP fixtures and the CalDAV free-busy expectations, and the e2e
legacy-protocol refusals. The webdav, imap and jmap suites pass, so do
the unit tests of every crate touched, and 73 of 75 SMTP tests; of the
other two, antispam fails on main too, and queue_retry is a timing flake
that passes on its own.
2026-09-22 20:08:10 -07:00

634 lines
25 KiB
Rust

/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Turning the legacy-protocols switch, and making it true of the running
//! server (legacy-protocols spec, LP-1, LP-2 and LP-5).
//!
//! Two halves meet here. `inbuxa_features::security` decides what the policy
//! means and owns the listener **objects**; [`ListenerControl`] owns the
//! running **sockets**. Neither can do the job alone, and only `Server` has
//! both, so the join lives here.
//!
//! Order matters in both directions. Closing removes the object first and then
//! stops the socket: a socket stopped before its object is gone would come
//! back on the next restart. Opening puts the object back first and then
//! spawns, for the same reason in reverse.
//!
//! Sign-in is the second lock (LP-6): while the switch is off, a sign-in over
//! 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 -- the server's, or for a tenant's
//! domains, the tenant's (LP-14a).
//!
//! 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.
use crate::{Server, auth::AccessToken, config::server::Listeners, network::TcpAcceptor};
use directory::Credentials;
use inbuxa_features::security::{
legacy_use::{self, LegacyUse},
listeners,
protocol_policy::{self, ProtocolPolicy, SavedListener},
tenant_protocol_policy,
};
use registry::schema::enums::ServiceProtocol;
use registry::types::{error::Error, id::ObjectId};
use store::registry::bootstrap::Bootstrap;
/// What turning the switch actually did.
#[derive(Debug, Default)]
pub struct PolicyChange {
/// Listeners removed and stopped (LP-1).
pub closed: Vec<SavedListener>,
/// Listeners put back and started again (LP-5).
pub reopened: Vec<SavedListener>,
/// Listeners that could not be put back, with the reason. Each stays
/// saved for another try (LP-5).
pub failed: Vec<(SavedListener, String)>,
/// Properties the locks overruled (LP-21).
pub overruled: Vec<&'static str>,
/// Listeners whose object is right but whose socket needs a restart,
/// because no spawner was left behind. Empty on a normally booted server.
pub pending_restart: Vec<String>,
}
impl PolicyChange {
/// Whether anything at all happened, for the caller deciding to emit
/// `security.legacy-protocols-changed` (LP-8).
pub fn is_empty(&self) -> bool {
self.closed.is_empty()
&& self.reopened.is_empty()
&& self.failed.is_empty()
&& self.overruled.is_empty()
}
}
impl Server {
/// The policy in force.
pub async fn protocol_policy(&self) -> trc::Result<ProtocolPolicy> {
protocol_policy::get(&self.core.storage.data).await
}
/// Turns the switch, and makes it true of the running server.
///
/// `requested` is what the client asked for; the locks are applied to it
/// first (LP-21), so what gets stored is what the server allows, not what
/// was asked. Returns what actually happened, for the response and the
/// event.
pub async fn set_protocol_policy(
&self,
requested: ProtocolPolicy,
changed_by: Option<String>,
) -> trc::Result<PolicyChange> {
let mut policy = requested;
let mut change = PolicyChange {
overruled: policy.apply_locks(),
..Default::default()
};
// Carry forward what earlier changes saved: the client never sets
// this, and a /set that omitted it must not lose the listeners still
// waiting to come back.
let previous = self.protocol_policy().await?;
policy.saved_listeners = previous.saved_listeners;
policy.changed_at = Some(store::write::now() * 1000);
policy.changed_by = changed_by;
if policy.legacy_protocols.is_disabled() {
self.close_legacy_listeners(&mut policy, &mut change).await?;
} else {
self.reopen_legacy_listeners(&mut policy, &mut change)
.await?;
}
protocol_policy::set(&self.core.storage.data, &policy).await?;
// LP-8. Raised here rather than by the JMAP method, so whatever turns
// the switch is reported. A /set that changed nothing -- the switch
// already where it was asked to be, nothing to close or reopen -- is
// not a change.
if previous.legacy_protocols != policy.legacy_protocols || !change.is_empty() {
let (moved, direction) = if policy.legacy_protocols.is_disabled() {
(&change.closed, "closed")
} else {
(&change.reopened, "reopened")
};
trc::event!(
Security(trc::SecurityEvent::LegacyProtocolsChanged),
Policy = "server",
Value = if policy.legacy_protocols.is_disabled() {
"disabled"
} else {
"enabled"
},
AccountId = policy.changed_by.clone(),
Details = direction,
ListenerId = listener_names(moved.iter().map(|l| l.id.clone())),
// Only when a listener could not be put back (LP-5).
Reason = (!change.failed.is_empty()).then(|| listener_names(
change
.failed
.iter()
.map(|(l, why)| format!("{}: {why}", l.id))
)),
);
}
Ok(change)
}
/// Removes the listener objects the policy closes, then stops their
/// sockets (LP-1, LP-2).
async fn close_legacy_listeners(
&self,
policy: &mut ProtocolPolicy,
change: &mut PolicyChange,
) -> trc::Result<()> {
let removed = listeners::close(self.registry(), policy).await?;
for saved in &removed {
// The runtime registry is keyed by the listener's name, which is
// what `close` returns as the saved listener's id.
self.inner.data.listener_control.stop(&saved.id);
}
policy.saved_listeners.extend(removed.iter().cloned());
change.closed = removed;
Ok(())
}
/// Puts back every saved listener and starts it again (LP-5).
async fn reopen_legacy_listeners(
&self,
policy: &mut ProtocolPolicy,
change: &mut PolicyChange,
) -> trc::Result<()> {
if policy.saved_listeners.is_empty() {
return Ok(());
}
let saved = std::mem::take(&mut policy.saved_listeners);
let (restored, failed) = listeners::reopen(self.registry(), &saved).await?;
// A listener that could not be put back stays saved for another try.
policy.saved_listeners = failed.iter().map(|(listener, _)| listener.clone()).collect();
change.failed = failed;
if !restored.is_empty() {
change.pending_restart = self.spawn_restored_listeners(&restored).await?;
}
change.reopened = restored;
Ok(())
}
/// Binds and spawns the listeners just put back, so a port opens without a
/// restart. Returns the names that still need one.
async fn spawn_restored_listeners(&self, restored: &[SavedListener]) -> trc::Result<Vec<String>> {
let control = &self.inner.data.listener_control;
if !control.can_spawn() {
return Ok(restored.iter().map(|listener| listener.id.clone()).collect());
}
// Re-parse from the registry rather than from the saved object: the
// socket has to be created and bound afresh, and the parser is what
// knows how. The objects are already back, so this sees them.
let mut bootstrap = Bootstrap::new(self.registry().clone()).await;
let mut parsed = Listeners::parse(&mut bootstrap).await;
parsed
.parse_tcp_acceptors(&mut bootstrap, self.inner.clone())
.await;
// Only the wanted listeners, so re-parsing does not bind a port some
// other listener already holds.
let wanted: Vec<&str> = restored.iter().map(|l| l.id.as_str()).collect();
parsed
.servers
.retain(|listener| wanted.contains(&listener.id.as_str()));
// Bind, but do not drop privileges again. A port below 1024 fails
// here once privileges are gone; that listener is reported as needing
// a restart rather than quietly left dead.
let errors_before = bootstrap.errors.len();
parsed.bind(&mut bootstrap);
let unbindable: Vec<ObjectId> = bootstrap.errors[errors_before..]
.iter()
.filter_map(|error| match error {
Error::Build { object_id, .. } => Some(*object_id),
_ => None,
})
.collect();
parsed
.servers
.retain(|listener| !unbindable.contains(&listener.registry_id));
let mut spawned = Vec::new();
let mut acceptors = std::mem::take(&mut parsed.tcp_acceptors);
for listener in parsed.servers {
if !wanted.contains(&listener.id.as_str()) || control.is_running(&listener.id) {
continue;
}
let acceptor = acceptors
.remove(&listener.id)
.unwrap_or(TcpAcceptor::Plain);
let id = listener.id.clone();
if control.spawn(listener, acceptor) {
spawned.push(id);
}
}
Ok(restored
.iter()
.map(|listener| listener.id.clone())
.filter(|id| !spawned.contains(id))
.collect())
}
}
/// Names for an event field: the listeners a change closed, reopened or
/// failed to reopen (LP-8).
fn listener_names<T: Into<trc::Value>>(names: impl Iterator<Item = T>) -> trc::Value {
trc::Value::Array(names.map(Into::into).collect())
}
/// A protocol a mail app signs in over, which the switch refuses (LP-6).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LegacyProtocol {
Imap,
Pop3,
ManageSieve,
/// SMTP AUTH, on any SMTP listener: only mail apps authenticate, so
/// inbound delivery is untouched (LP-3).
Submission,
}
impl LegacyProtocol {
pub fn as_str(&self) -> &'static str {
match self {
LegacyProtocol::Imap => "imap",
LegacyProtocol::Pop3 => "pop3",
LegacyProtocol::ManageSieve => "manageSieve",
LegacyProtocol::Submission => "submission",
}
}
/// The same protocol, as the impact panel's record names it (LP-15).
pub fn as_use(&self) -> LegacyUse {
match self {
LegacyProtocol::Imap => LegacyUse::Imap,
LegacyProtocol::Pop3 => LegacyUse::Pop3,
LegacyProtocol::ManageSieve => LegacyUse::ManageSieve,
LegacyProtocol::Submission => LegacyUse::Submission,
}
}
/// What the mail app is told (LP-12). Each protocol's own framing --
/// IMAP's `[ALERT]`, ManageSieve's quoting -- is added by its session;
/// POP3 carries `[AUTH]` in the text, since its errors have no separate
/// code, and SMTP is the whole reply line. At server scope "Your
/// organization" reads "This server" (LP-6).
pub fn refusal(&self, scope: RefusalScope) -> &'static str {
match (scope, self) {
(RefusalScope::Server, LegacyProtocol::Imap) => {
"This server allows only inbuxa webmail and JMAP apps. This mail app can't sign in."
}
(RefusalScope::Server, LegacyProtocol::Pop3) => {
"[AUTH] This server allows only inbuxa webmail and JMAP apps. This mail app can't sign in."
}
(RefusalScope::Server, LegacyProtocol::ManageSieve) => {
"This server allows only inbuxa webmail and JMAP apps."
}
(RefusalScope::Server, LegacyProtocol::Submission) => {
"535 5.7.0 This server allows only inbuxa webmail and JMAP apps. This mail app can't send.\r\n"
}
(RefusalScope::Tenant(_), LegacyProtocol::Imap) => {
"Your organization allows only inbuxa webmail and JMAP apps. This mail app can't sign in."
}
(RefusalScope::Tenant(_), LegacyProtocol::Pop3) => {
"[AUTH] Your organization allows only inbuxa webmail and JMAP apps. This mail app can't sign in."
}
(RefusalScope::Tenant(_), LegacyProtocol::ManageSieve) => {
"Your organization allows only inbuxa webmail and JMAP apps."
}
(RefusalScope::Tenant(_), LegacyProtocol::Submission) => {
"535 5.7.0 Your organization allows only inbuxa webmail and JMAP apps. This mail app can't send.\r\n"
}
}
}
/// The refusal as an error: `auth.legacy-protocol-refused`, not
/// `auth.failed`, so it never counts against the account or feeds the
/// auto-ban (LP-11). It names the protocol, the scope and the domain,
/// never the account; the session adds the remote IP.
///
/// Not the tenant's id: `Id` is what IMAP answers a command's tag from,
/// so an error carrying one is sent under the wrong tag and the mail app
/// waits for a reply that never comes. The domain names the tenant.
pub fn refused(&self, scope: RefusalScope, domain: Option<String>) -> trc::Error {
trc::AuthEvent::LegacyProtocolRefused
.into_err()
.details(self.refusal(scope))
.ctx(trc::Key::Source, self.as_str())
.ctx(
trc::Key::Policy,
match scope {
RefusalScope::Server => "server",
RefusalScope::Tenant(_) => "tenant",
},
)
.ctx_opt(trc::Key::Domain, domain)
}
}
/// One account's last sign-in over one legacy protocol, as the impact panel
/// shows it (LP-15).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecentUse {
pub account_id: u32,
pub name: String,
pub protocol: &'static str,
/// Seconds since the epoch.
pub at: u64,
}
/// Whose switch refused a sign-in: the server's (LP-6) or a tenant's (LP-10).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RefusalScope {
Server,
Tenant(u32),
}
/// The domain a sign-in is for, from the name it gives, if it gives one.
fn domain_of(credentials: &Credentials) -> Option<String> {
let username = match credentials {
Credentials::Basic { username, .. } => Some(username.as_str()),
Credentials::Bearer { username, .. } => username.as_deref(),
}?;
username
.rsplit_once('@')
.map(|(_, domain)| domain.trim().to_lowercase())
.filter(|domain| !domain.is_empty())
}
impl Server {
/// Refuses a sign-in over a legacy protocol while the server-wide switch
/// is off (LP-6), or while the switch of the tenant that owns the named
/// domain is (LP-10). Called before the credentials are checked, so the
/// answer is the same for a right password, a wrong one and an address
/// that doesn't exist (LP-11): a tenant's domain answers for every address
/// on it.
///
/// Read from the store on each sign-in rather than cached, so every node
/// of a cluster answers the same the moment a switch turns.
pub async fn refuse_legacy_sign_in(
&self,
protocol: LegacyProtocol,
credentials: &Credentials,
) -> trc::Result<()> {
let domain = domain_of(credentials);
if self.protocol_policy().await?.legacy_protocols.is_disabled() {
return Err(protocol.refused(RefusalScope::Server, domain));
}
if let Some(name) = &domain
&& let Some(domain) = self.domain(name).await?
&& let Some(tenant_id) = domain.id_tenant
&& self.tenant_legacy_protocols_off(tenant_id).await?
{
return Err(protocol.refused(RefusalScope::Tenant(tenant_id), Some(name.clone())));
}
Ok(())
}
/// Once the account is known: refuses it if its tenant has legacy
/// protocols off, and otherwise records the sign-in for the impact panel.
///
/// The refusal is LP-10 again for a bearer token, which needn't name an
/// account and so can't be judged by its domain beforehand; for a
/// password sign-in it has already been decided. The record is LP-15's:
/// one timestamp per account and protocol, at most hourly. A record that
/// can't be written is logged and the sign-in goes ahead -- a panel is
/// not worth locking anyone out over.
pub async fn admit_legacy_session(
&self,
protocol: LegacyProtocol,
access_token: &AccessToken,
) -> trc::Result<()> {
if let Some(tenant_id) = access_token.tenant_id()
&& self.tenant_legacy_protocols_off(tenant_id).await?
{
return Err(protocol.refused(RefusalScope::Tenant(tenant_id), None));
}
if let Err(err) = legacy_use::record(
&self.core.storage.data,
access_token.account_id(),
protocol.as_use(),
store::write::now(),
)
.await
{
trc::error!(err.details("Failed to record a legacy sign-in (LP-15)."));
}
Ok(())
}
/// Who signed in over a legacy protocol in the last 30 days, most recent
/// first, for the impact panel (LP-15): everyone at server scope, or one
/// tenant's accounts. Accounts that no longer exist are left out.
pub async fn recent_legacy_use(&self, tenant_id: Option<u32>) -> trc::Result<Vec<RecentUse>> {
let mut recent = Vec::new();
for entry in legacy_use::recent(&self.core.storage.data, store::write::now()).await? {
let Some(account) = self.try_account(entry.account_id).await? else {
continue;
};
if tenant_id.is_some() && account.id_tenant != tenant_id {
continue;
}
recent.push(RecentUse {
account_id: entry.account_id,
name: account.name.to_string(),
protocol: entry.protocol.as_str(),
at: entry.at,
});
}
recent.sort_by(|a, b| b.at.cmp(&a.at).then_with(|| a.name.cmp(&b.name)));
Ok(recent)
}
/// Whether legacy protocols are off for this account: the stricter of the
/// server's switch and its tenant's. What the JMAP session tells the
/// account's apps (legacy-protocols spec, Interfaces), so the webmail can
/// say why a mail app won't connect (LP-19).
pub async fn legacy_protocols_off_for_account(
&self,
access_token: &AccessToken,
) -> trc::Result<bool> {
if self.protocol_policy().await?.legacy_protocols.is_disabled() {
return Ok(true);
}
match access_token.tenant_id() {
Some(tenant_id) => self.tenant_legacy_protocols_off(tenant_id).await,
None => Ok(false),
}
}
/// Whether a tenant has turned legacy protocols off for itself (LP-10).
pub async fn tenant_legacy_protocols_off(&self, tenant_id: u32) -> trc::Result<bool> {
Ok(
tenant_protocol_policy::get(&self.core.storage.data, tenant_id)
.await?
.legacy_protocols
.is_disabled(),
)
}
}
/// 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 legacy services are off for this domain, for the answers that
/// must stop offering them: off for the whole server (LP-7), or for the
/// tenant the domain belongs to (LP-14a). Read per answer, as sign-in
/// reads it. A name that is no domain here answers for the server alone.
pub async fn legacy_protocols_off_for(&self, domain_name: &str) -> trc::Result<bool> {
if self.protocol_policy().await?.legacy_protocols.is_disabled() {
return Ok(true);
}
match self.domain(domain_name).await? {
Some(domain) => match domain.id_tenant {
Some(tenant_id) => self.tenant_legacy_protocols_off(tenant_id).await,
None => Ok(false),
},
None => Ok(false),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn basic(username: &str) -> Credentials {
Credentials::Basic {
username: username.to_string(),
secret: "wrong or right, it is never read".to_string(),
mfa_token: None,
}
}
#[test]
fn refusals_read_as_the_spec_writes_them() {
// LP-12, with "Your organization" read as "This server" (LP-6).
let server = RefusalScope::Server;
assert_eq!(
LegacyProtocol::Imap.refusal(server),
"This server allows only inbuxa webmail and JMAP apps. This mail app can't sign in."
);
assert!(
LegacyProtocol::Pop3
.refusal(server)
.starts_with("[AUTH] This server allows")
);
assert_eq!(
LegacyProtocol::ManageSieve.refusal(server),
"This server allows only inbuxa webmail and JMAP apps."
);
assert_eq!(
LegacyProtocol::Submission.refusal(server),
"535 5.7.0 This server allows only inbuxa webmail and JMAP apps. This mail app can't send.\r\n"
);
}
#[test]
fn a_tenant_refusal_speaks_for_the_organization() {
// LP-12, exactly as the spec writes them.
let tenant = RefusalScope::Tenant(7);
assert_eq!(
LegacyProtocol::Imap.refusal(tenant),
"Your organization allows only inbuxa webmail and JMAP apps. This mail app can't sign in."
);
assert_eq!(
LegacyProtocol::Pop3.refusal(tenant),
"[AUTH] Your organization allows only inbuxa webmail and JMAP apps. This mail app can't sign in."
);
assert_eq!(
LegacyProtocol::ManageSieve.refusal(tenant),
"Your organization allows only inbuxa webmail and JMAP apps."
);
assert_eq!(
LegacyProtocol::Submission.refusal(tenant),
"535 5.7.0 Your organization allows only inbuxa webmail and JMAP apps. This mail app can't send.\r\n"
);
let err = LegacyProtocol::Imap.refused(tenant, Some("example.org".into()));
assert_eq!(err.value_as_str(trc::Key::Policy), Some("tenant"));
// IMAP answers the command's tag from Id; the refusal must leave it be.
assert!(err.value(trc::Key::Id).is_none());
assert!(err.matches(trc::EventType::Auth(trc::AuthEvent::LegacyProtocolRefused)));
}
#[test]
fn a_refusal_is_not_a_failed_sign_in() {
let err = LegacyProtocol::Imap
.refused(RefusalScope::Server, domain_of(&basic("[email protected]")));
assert!(err.matches(trc::EventType::Auth(trc::AuthEvent::LegacyProtocolRefused)));
assert!(!err.matches(trc::EventType::Auth(trc::AuthEvent::Failed)));
// The session stays open: the mail app is told, not thrown off.
assert!(!err.must_disconnect());
assert!(err.should_write_err());
assert_eq!(err.value_as_str(trc::Key::Domain), Some("example.org"));
assert_eq!(err.value_as_str(trc::Key::Source), Some("imap"));
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()));
assert_eq!(domain_of(&basic("no-domain")), None);
assert_eq!(domain_of(&basic("trailing@")), None);
let bearer = Credentials::Bearer {
username: None,
token: "t".to_string(),
};
assert_eq!(domain_of(&bearer), None);
}
}