Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de275bac60 | ||
|
|
305406a331 | ||
|
|
f5888d79b0 | ||
|
|
8e9cedbe97 | ||
|
|
5ba54e8fb7 | ||
|
|
db817dd507 | ||
|
|
b7e3a765ca | ||
|
|
7ba9ec9fa0 | ||
|
|
4c07779c16 | ||
|
|
e1e8a9aeb0 | ||
|
|
b1bc5ed6e0 |
@@ -46,10 +46,10 @@ 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,
|
||||
/// inbuxa: the document once per combination of legacy protocols off,
|
||||
/// indexed by `LegacyOff::index` (legacy-protocols LP-7, one switch per
|
||||
/// protocol); index 0 is the full document.
|
||||
pub pacc: Vec<Pacc>,
|
||||
pub mxs: Vec<MailExchanger>,
|
||||
pub services: VecMap<ServiceProtocol, Service>,
|
||||
}
|
||||
@@ -333,16 +333,27 @@ impl Network {
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
// inbuxa: legacy-protocols LP-7
|
||||
let pacc_jmap_only = {
|
||||
// inbuxa: legacy-protocols LP-7, one document per combination of
|
||||
// protocols off, bits as `LegacyOff::index`: IMAP, POP3, ManageSieve,
|
||||
// submission.
|
||||
let pacc = (0..16usize)
|
||||
.map(|off| {
|
||||
let mut pacc = pacc.clone();
|
||||
if off & 1 != 0 {
|
||||
pacc.protocols.imap = None;
|
||||
}
|
||||
if off & 2 != 0 {
|
||||
pacc.protocols.pop3 = None;
|
||||
pacc.protocols.smtp = None;
|
||||
}
|
||||
if off & 4 != 0 {
|
||||
pacc.protocols.managesieve = None;
|
||||
}
|
||||
if off & 8 != 0 {
|
||||
pacc.protocols.smtp = None;
|
||||
}
|
||||
split(&pacc)
|
||||
};
|
||||
let pacc = split(&pacc);
|
||||
})
|
||||
.collect();
|
||||
let mut network = Network {
|
||||
node_id: bp.node_id() as u64,
|
||||
server_name: default_hostname.to_string(),
|
||||
@@ -358,7 +369,6 @@ impl Network {
|
||||
mxs: system.mail_exchangers.into_iter().collect(),
|
||||
services: system.services,
|
||||
pacc,
|
||||
pacc_jmap_only,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use crate::{Server, manager::application::Resource, network::legacy::is_legacy_service};
|
||||
use crate::{Server, manager::application::Resource};
|
||||
use quick_xml::Reader;
|
||||
use quick_xml::XmlVersion;
|
||||
use quick_xml::events::Event;
|
||||
@@ -59,11 +59,11 @@ impl Server {
|
||||
let _ = writeln!(&mut config, "\t\t\t<Action>settings</Action>");
|
||||
// inbuxa: legacy-protocols LP-7, LP-14a
|
||||
let legacy_off = match emailaddress.rsplit_once('@') {
|
||||
Some((_, domain)) => self.legacy_protocols_off_for(domain).await?,
|
||||
None => self.legacy_protocols_off_for("").await?,
|
||||
Some((_, domain)) => self.legacy_off_for(domain).await?,
|
||||
None => self.legacy_off_for("").await?,
|
||||
};
|
||||
for (protocol, service) in &self.core.network.info.services {
|
||||
if legacy_off && is_legacy_service(protocol) {
|
||||
if legacy_off.service(protocol) {
|
||||
continue;
|
||||
}
|
||||
let (protocol, ports) = match protocol {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use crate::{Server, manager::application::Resource, network::legacy::is_legacy_service};
|
||||
use crate::{Server, manager::application::Resource};
|
||||
use registry::schema::enums::ServiceProtocol;
|
||||
use std::fmt::Write;
|
||||
use utils::url_params::UrlParams;
|
||||
@@ -31,7 +31,7 @@ impl Server {
|
||||
};
|
||||
|
||||
// inbuxa: legacy-protocols LP-7, LP-14a
|
||||
let legacy_off = self.legacy_protocols_off_for(domain).await?;
|
||||
let legacy_off = self.legacy_off_for(domain).await?;
|
||||
|
||||
// Build XML response
|
||||
let mut config = String::with_capacity(1024);
|
||||
@@ -45,7 +45,7 @@ impl Server {
|
||||
"\t\t<displayShortName>{domain}</displayShortName>"
|
||||
);
|
||||
for (protocol, service) in &self.core.network.info.services {
|
||||
if legacy_off && is_legacy_service(protocol) {
|
||||
if legacy_off.service(protocol) {
|
||||
continue;
|
||||
}
|
||||
let (protocol, tag, ports) = match protocol {
|
||||
|
||||
@@ -6,11 +6,7 @@
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
Server,
|
||||
config::network::Pacc,
|
||||
network::{dkim::generate_dkim_dns_record, legacy::is_legacy_service},
|
||||
};
|
||||
use crate::{Server, config::network::Pacc, network::dkim::generate_dkim_dns_record};
|
||||
use ahash::{AHashMap, AHashSet};
|
||||
use base64::{Engine, engine::general_purpose};
|
||||
use dns_update::{
|
||||
@@ -41,7 +37,7 @@ impl Server {
|
||||
let default_host = network.server_name.as_str();
|
||||
let domain_name = domain.name.as_str();
|
||||
// inbuxa: legacy-protocols LP-7, LP-14a
|
||||
let legacy_off = self.legacy_protocols_off_for(domain_name).await?;
|
||||
let legacy_off = self.legacy_off_for(domain_name).await?;
|
||||
let domain_name_suffix = format!(".{domain_name}");
|
||||
|
||||
for record_type in record_types {
|
||||
@@ -205,7 +201,7 @@ impl Server {
|
||||
// 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) {
|
||||
if legacy_off.service(protocol) {
|
||||
for (service_name, _) in services {
|
||||
records.push(NamedDnsRecord {
|
||||
name: format!("_{service_name}._tcp.{domain_name}."),
|
||||
@@ -307,8 +303,8 @@ impl Server {
|
||||
// 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)
|
||||
if matches!(protocol, ServiceProtocol::Imap | ServiceProtocol::Pop3)
|
||||
&& legacy_off.service(protocol)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -418,11 +414,8 @@ impl Server {
|
||||
|
||||
pub async fn get_pacc_for_domain(&self, domain_name: &str) -> trc::Result<String> {
|
||||
// inbuxa: legacy-protocols LP-7, LP-14a
|
||||
let pacc = if self.legacy_protocols_off_for(domain_name).await? {
|
||||
&self.core.network.info.pacc_jmap_only
|
||||
} else {
|
||||
&self.core.network.info.pacc
|
||||
};
|
||||
let off = self.legacy_off_for(domain_name).await?;
|
||||
let pacc = &self.core.network.info.pacc[off.index()];
|
||||
self.get_directory_for_domain(domain_name)
|
||||
.await
|
||||
.caused_by(trc::location!())
|
||||
|
||||
@@ -35,8 +35,8 @@ use directory::Credentials;
|
||||
use inbuxa_features::security::{
|
||||
legacy_use::{self, LegacyUse},
|
||||
listeners,
|
||||
protocol_policy::{self, ProtocolPolicy, SavedListener},
|
||||
tenant_protocol_policy,
|
||||
protocol_policy::{self, ProtocolPolicy, SUBMISSION, SWITCHED, SavedListener, Switches},
|
||||
tenant_protocol_policy::{self, OffBy, TenantProtocolPolicy},
|
||||
};
|
||||
use registry::schema::enums::ServiceProtocol;
|
||||
use registry::types::{error::Error, id::ObjectId};
|
||||
@@ -97,40 +97,48 @@ impl Server {
|
||||
// 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.saved_listeners = previous.saved_listeners.clone();
|
||||
policy.changed_at = Some(store::write::now() * 1000);
|
||||
policy.changed_by = changed_by;
|
||||
policy.normalize();
|
||||
|
||||
if policy.legacy_protocols.is_disabled() {
|
||||
self.close_legacy_listeners(&mut policy, &mut change).await?;
|
||||
} else {
|
||||
// Each protocol on its own switch: close what is off now, and put
|
||||
// back what was saved for a protocol that is on again. Either may
|
||||
// happen in one change, when one protocol goes off as another comes
|
||||
// back.
|
||||
self.close_legacy_listeners(&mut policy, &mut change)
|
||||
.await?;
|
||||
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
|
||||
// a switch is reported. A /set that changed nothing -- every 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")
|
||||
};
|
||||
let mut before = previous;
|
||||
before.normalize();
|
||||
if before.off() != policy.off() || !change.is_empty() {
|
||||
// The closed first, then the reopened; `Details` says which.
|
||||
let moved = change
|
||||
.closed
|
||||
.iter()
|
||||
.chain(change.reopened.iter())
|
||||
.map(|l| l.id.clone());
|
||||
trc::event!(
|
||||
Security(trc::SecurityEvent::LegacyProtocolsChanged),
|
||||
Policy = "server",
|
||||
Value = if policy.legacy_protocols.is_disabled() {
|
||||
"disabled"
|
||||
} else {
|
||||
"enabled"
|
||||
},
|
||||
Value = switches_value(&policy),
|
||||
AccountId = policy.changed_by.clone(),
|
||||
Details = direction,
|
||||
ListenerId = listener_names(moved.iter().map(|l| l.id.clone())),
|
||||
Details = if change.closed.is_empty() {
|
||||
"reopened"
|
||||
} else if change.reopened.is_empty() {
|
||||
"closed"
|
||||
} else {
|
||||
"closed and reopened"
|
||||
},
|
||||
ListenerId = listener_names(moved),
|
||||
// Only when a listener could not be put back (LP-5).
|
||||
Reason = (!change.failed.is_empty()).then(|| listener_names(
|
||||
change
|
||||
@@ -165,21 +173,27 @@ impl Server {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Puts back every saved listener and starts it again (LP-5).
|
||||
/// Puts back every saved listener whose protocol is on again, and starts
|
||||
/// it (LP-5). The rest stay saved.
|
||||
async fn reopen_legacy_listeners(
|
||||
&self,
|
||||
policy: &mut ProtocolPolicy,
|
||||
change: &mut PolicyChange,
|
||||
) -> trc::Result<()> {
|
||||
if policy.saved_listeners.is_empty() {
|
||||
let (wanted, still_closed): (Vec<_>, Vec<_>) = std::mem::take(&mut policy.saved_listeners)
|
||||
.into_iter()
|
||||
.partition(|saved| !policy.closes(&saved.protocol, &saved.ports));
|
||||
policy.saved_listeners = still_closed;
|
||||
if wanted.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let saved = std::mem::take(&mut policy.saved_listeners);
|
||||
let (restored, failed) = listeners::reopen(self.registry(), &saved).await?;
|
||||
let (restored, failed) = listeners::reopen(self.registry(), &wanted).await?;
|
||||
|
||||
// A listener that could not be put back stays saved for another try.
|
||||
policy.saved_listeners = failed.iter().map(|(listener, _)| listener.clone()).collect();
|
||||
policy
|
||||
.saved_listeners
|
||||
.extend(failed.iter().map(|(listener, _)| listener.clone()));
|
||||
change.failed = failed;
|
||||
|
||||
if !restored.is_empty() {
|
||||
@@ -254,6 +268,17 @@ impl Server {
|
||||
}
|
||||
}
|
||||
|
||||
/// The switches as an event value: `disabled` or `enabled` when all three
|
||||
/// agree, otherwise which are off, such as `pop3 disabled` (LP-8).
|
||||
pub fn switches_value(policy: &impl Switches) -> String {
|
||||
let off = policy.off();
|
||||
match off.len() {
|
||||
0 => "enabled".to_string(),
|
||||
n if n == SWITCHED.len() => "disabled".to_string(),
|
||||
_ => format!("{} disabled", off.join(", ")),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -395,16 +420,19 @@ impl Server {
|
||||
credentials: &Credentials,
|
||||
) -> trc::Result<()> {
|
||||
let domain = domain_of(credentials);
|
||||
if self.protocol_policy().await?.legacy_protocols.is_disabled() {
|
||||
let server = self.protocol_policy().await?;
|
||||
if server.is_off(protocol.as_str()) {
|
||||
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?
|
||||
{
|
||||
let tenant = self.tenant_protocol_policy(tenant_id).await?;
|
||||
if tenant_protocol_policy::off_by(&server, Some(&tenant), protocol.as_str()).is_some() {
|
||||
return Err(protocol.refused(RefusalScope::Tenant(tenant_id), Some(name.clone())));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -422,11 +450,17 @@ impl Server {
|
||||
protocol: LegacyProtocol,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<()> {
|
||||
if let Some(tenant_id) = access_token.tenant_id()
|
||||
&& self.tenant_legacy_protocols_off(tenant_id).await?
|
||||
{
|
||||
if let Some(tenant_id) = access_token.tenant_id() {
|
||||
let server = self.protocol_policy().await?;
|
||||
let tenant = self.tenant_protocol_policy(tenant_id).await?;
|
||||
match tenant_protocol_policy::off_by(&server, Some(&tenant), protocol.as_str()) {
|
||||
Some(OffBy::Server) => return Err(protocol.refused(RefusalScope::Server, None)),
|
||||
Some(OffBy::Tenant) => {
|
||||
return Err(protocol.refused(RefusalScope::Tenant(tenant_id), None));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
if let Err(err) = legacy_use::record(
|
||||
&self.core.storage.data,
|
||||
access_token.account_id(),
|
||||
@@ -463,31 +497,96 @@ impl Server {
|
||||
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
|
||||
/// Which legacy protocols are off for this account: each 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(
|
||||
pub async fn legacy_off_for_account(
|
||||
&self,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<bool> {
|
||||
if self.protocol_policy().await?.legacy_protocols.is_disabled() {
|
||||
return Ok(true);
|
||||
) -> trc::Result<LegacyOff> {
|
||||
let server = self.protocol_policy().await?;
|
||||
let tenant = match access_token.tenant_id() {
|
||||
Some(tenant_id) => Some(self.tenant_protocol_policy(tenant_id).await?),
|
||||
None => None,
|
||||
};
|
||||
Ok(LegacyOff::of(&server, tenant.as_ref()))
|
||||
}
|
||||
match access_token.tenant_id() {
|
||||
Some(tenant_id) => self.tenant_legacy_protocols_off(tenant_id).await,
|
||||
None => Ok(false),
|
||||
|
||||
/// A tenant's switches, or all on when it has never set them (LP-10).
|
||||
pub async fn tenant_protocol_policy(
|
||||
&self,
|
||||
tenant_id: u32,
|
||||
) -> trc::Result<TenantProtocolPolicy> {
|
||||
tenant_protocol_policy::get(&self.core.storage.data, tenant_id).await
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(),
|
||||
)
|
||||
/// Which legacy protocols are off, for one account or one domain: the server's
|
||||
/// switches and the tenant's together. Submission is off only when all three
|
||||
/// are.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct LegacyOff {
|
||||
pub imap: bool,
|
||||
pub pop3: bool,
|
||||
pub manage_sieve: bool,
|
||||
pub submission: bool,
|
||||
}
|
||||
|
||||
impl LegacyOff {
|
||||
pub fn of(server: &ProtocolPolicy, tenant: Option<&TenantProtocolPolicy>) -> Self {
|
||||
let off = |protocol| tenant_protocol_policy::off_by(server, tenant, protocol).is_some();
|
||||
LegacyOff {
|
||||
imap: off("imap"),
|
||||
pop3: off("pop3"),
|
||||
manage_sieve: off("manageSieve"),
|
||||
submission: off(SUBMISSION),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this configured service must not be offered (LP-7). SMTP here
|
||||
/// is submission; inbound mail is never a configured service.
|
||||
pub fn service(&self, protocol: &ServiceProtocol) -> bool {
|
||||
match protocol {
|
||||
ServiceProtocol::Imap => self.imap,
|
||||
ServiceProtocol::Pop3 => self.pop3,
|
||||
ServiceProtocol::Managesieve => self.manage_sieve,
|
||||
ServiceProtocol::Smtp => self.submission,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether anything is off.
|
||||
pub fn any(&self) -> bool {
|
||||
self.imap || self.pop3 || self.manage_sieve || self.submission
|
||||
}
|
||||
|
||||
/// Whether everything is off: the kill-all's effect.
|
||||
pub fn all(&self) -> bool {
|
||||
self.imap && self.pop3 && self.manage_sieve && self.submission
|
||||
}
|
||||
|
||||
/// An index for answers prepared once per combination (the PACC
|
||||
/// document): one bit per protocol.
|
||||
pub fn index(&self) -> usize {
|
||||
(self.imap as usize)
|
||||
| (self.pop3 as usize) << 1
|
||||
| (self.manage_sieve as usize) << 2
|
||||
| (self.submission as usize) << 3
|
||||
}
|
||||
|
||||
/// The protocols that are still allowed, by JMAP name, for the session.
|
||||
pub fn allowed(&self) -> Vec<&'static str> {
|
||||
[
|
||||
("imap", self.imap),
|
||||
("pop3", self.pop3),
|
||||
("manageSieve", self.manage_sieve),
|
||||
(SUBMISSION, self.submission),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|(_, off)| !off)
|
||||
.map(|(name, _)| name)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -505,21 +604,20 @@ pub fn is_legacy_service(protocol: &ServiceProtocol) -> bool {
|
||||
}
|
||||
|
||||
impl Server {
|
||||
/// Whether legacy services are off for this domain, for the answers that
|
||||
/// Which 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? {
|
||||
pub async fn legacy_off_for(&self, domain_name: &str) -> trc::Result<LegacyOff> {
|
||||
let server = self.protocol_policy().await?;
|
||||
let tenant = 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),
|
||||
Some(tenant_id) => Some(self.tenant_protocol_policy(tenant_id).await?),
|
||||
None => None,
|
||||
},
|
||||
None => Ok(false),
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
Ok(LegacyOff::of(&server, tenant.as_ref()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -619,6 +717,36 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_is_off_for_one_account_or_domain() {
|
||||
use inbuxa_features::security::protocol_policy::LegacyProtocols;
|
||||
let mut server = ProtocolPolicy::default();
|
||||
server.set("pop3", LegacyProtocols::Disabled);
|
||||
let mut tenant = TenantProtocolPolicy::default();
|
||||
tenant.set("manageSieve", LegacyProtocols::Disabled);
|
||||
|
||||
let off = LegacyOff::of(&server, Some(&tenant));
|
||||
assert!(off.pop3 && off.manage_sieve && !off.imap && !off.submission);
|
||||
assert!(off.service(&ServiceProtocol::Pop3));
|
||||
assert!(!off.service(&ServiceProtocol::Imap));
|
||||
assert!(
|
||||
!off.service(&ServiceProtocol::Smtp),
|
||||
"sending is still offered"
|
||||
);
|
||||
assert!(!off.service(&ServiceProtocol::Jmap));
|
||||
assert_eq!(off.allowed(), vec!["imap", "submission"]);
|
||||
assert!(off.any() && !off.all());
|
||||
|
||||
let off = LegacyOff::of(&server, None);
|
||||
assert_eq!(off.index(), 0b0010);
|
||||
|
||||
server.set_all(LegacyProtocols::Disabled);
|
||||
let off = LegacyOff::of(&server, None);
|
||||
assert!(off.all());
|
||||
assert_eq!(off.index(), 0b1111);
|
||||
assert!(off.allowed().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_domain_comes_from_the_name_given() {
|
||||
assert_eq!(domain_of(&basic("[email protected]")), Some("b.test".to_string()));
|
||||
|
||||
@@ -8,6 +8,17 @@
|
||||
//! (legacy-protocols spec, data model and LP-1 to LP-8). Stored as JSON under
|
||||
//! `P` + `p` in the fork's subspace; unset fields read as the defaults.
|
||||
//!
|
||||
//! Each mail-app protocol has its own switch (legacy-protocols spec,
|
||||
//! "Revisit: one switch per protocol"): IMAP, POP3 and ManageSieve.
|
||||
//! `legacyProtocols` is the kill-all: setting it sets all three, and it reads
|
||||
//! `disabled` exactly when all three are off. A policy stored before the
|
||||
//! per-protocol switches has only `legacyProtocols`, and reads as all three
|
||||
//! at that value.
|
||||
//!
|
||||
//! SMTP submission has no switch of its own here: sign-in over it is refused
|
||||
//! only when all three are off, as it was by the single switch (LP-6), so
|
||||
//! turning off one protocol never stops a mail app sending.
|
||||
//!
|
||||
//! This module is the fact, not the act. It holds what the operator chose and
|
||||
//! which listeners were taken away to honour it. Closing sockets belongs to
|
||||
//! `common`, which owns the listener registry, and removing the listener
|
||||
@@ -59,12 +70,30 @@ pub struct SavedListener {
|
||||
pub object: serde_json::Value,
|
||||
}
|
||||
|
||||
/// The server-wide switch.
|
||||
/// The protocols with a switch of their own, as the schema and JMAP spell
|
||||
/// them.
|
||||
pub const SWITCHED: &[&str] = &["imap", "pop3", "manageSieve"];
|
||||
|
||||
/// The name sign-in uses for SMTP AUTH, which follows the kill-all.
|
||||
pub const SUBMISSION: &str = "submission";
|
||||
|
||||
/// The server-wide switches.
|
||||
#[derive(Debug, Clone, PartialEq, SerdeSerialize, SerdeDeserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct ProtocolPolicy {
|
||||
/// The switch itself.
|
||||
/// The kill-all: `disabled` exactly when all three protocols are off,
|
||||
/// once [`ProtocolPolicy::normalize`] has run. In a policy stored before
|
||||
/// the per-protocol switches, it is the value of all three.
|
||||
pub legacy_protocols: LegacyProtocols,
|
||||
/// IMAP's switch. Unset reads as `legacy_protocols`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub imap: Option<LegacyProtocols>,
|
||||
/// POP3's switch. Unset reads as `legacy_protocols`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pop3: Option<LegacyProtocols>,
|
||||
/// ManageSieve's switch. Unset reads as `legacy_protocols`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub manage_sieve: Option<LegacyProtocols>,
|
||||
/// With `disabled`, also close SMTP submission (LP-3). The inbound
|
||||
/// listener on port 25 is never closed, whatever this says.
|
||||
pub close_submission: bool,
|
||||
@@ -80,6 +109,9 @@ impl Default for ProtocolPolicy {
|
||||
fn default() -> Self {
|
||||
ProtocolPolicy {
|
||||
legacy_protocols: LegacyProtocols::Enabled,
|
||||
imap: None,
|
||||
pop3: None,
|
||||
manage_sieve: None,
|
||||
close_submission: true,
|
||||
saved_listeners: Vec::new(),
|
||||
changed_at: None,
|
||||
@@ -91,6 +123,9 @@ impl Default for ProtocolPolicy {
|
||||
/// The properties `inbuxa:ProtocolPolicy` has, as they appear over JMAP.
|
||||
pub const PROPERTIES: &[&str] = &[
|
||||
"legacyProtocols",
|
||||
"imap",
|
||||
"pop3",
|
||||
"manageSieve",
|
||||
"closeSubmission",
|
||||
"savedListeners",
|
||||
"changedAt",
|
||||
@@ -129,23 +164,137 @@ pub fn is_locked(protocol: &str) -> bool {
|
||||
.any(|locked| locked.eq_ignore_ascii_case(protocol))
|
||||
}
|
||||
|
||||
impl ProtocolPolicy {
|
||||
/// Whether a listener of this protocol and these ports is one the switch
|
||||
/// closes. A listener bound to port 25 is inbound whatever its name, and
|
||||
/// any other SMTP listener counts as submission (LP-3).
|
||||
pub fn closes(&self, protocol: &str, ports: &[u16]) -> bool {
|
||||
if !self.legacy_protocols.is_disabled() {
|
||||
return false;
|
||||
/// The switch fields, by protocol name.
|
||||
pub trait Switches {
|
||||
/// The kill-all, which an unset per-protocol switch reads as.
|
||||
fn all(&self) -> LegacyProtocols;
|
||||
fn slot(&self, protocol: &str) -> Option<&Option<LegacyProtocols>>;
|
||||
fn slot_mut(&mut self, protocol: &str) -> Option<&mut Option<LegacyProtocols>>;
|
||||
fn set_all_field(&mut self, value: LegacyProtocols);
|
||||
|
||||
/// One protocol's switch. `submission` follows the kill-all: it is off
|
||||
/// only when all three are. Anything else has no switch and is on.
|
||||
fn switch(&self, protocol: &str) -> LegacyProtocols {
|
||||
if protocol == SUBMISSION {
|
||||
return if self.all_off() {
|
||||
LegacyProtocols::Disabled
|
||||
} else {
|
||||
LegacyProtocols::Enabled
|
||||
};
|
||||
}
|
||||
match self.slot(protocol) {
|
||||
Some(value) => value.unwrap_or(self.all()),
|
||||
None => LegacyProtocols::Enabled,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this protocol is off.
|
||||
fn is_off(&self, protocol: &str) -> bool {
|
||||
self.switch(protocol).is_disabled()
|
||||
}
|
||||
|
||||
/// Whether all three protocols are off.
|
||||
fn all_off(&self) -> bool {
|
||||
SWITCHED.iter().all(|protocol| {
|
||||
self.slot(protocol)
|
||||
.and_then(|value| *value)
|
||||
.unwrap_or(self.all())
|
||||
.is_disabled()
|
||||
})
|
||||
}
|
||||
|
||||
/// Sets one protocol's switch; false if it has none.
|
||||
fn set(&mut self, protocol: &str, value: LegacyProtocols) -> bool {
|
||||
match self.slot_mut(protocol) {
|
||||
Some(slot) => {
|
||||
*slot = Some(value);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The kill-all: all three at once.
|
||||
fn set_all(&mut self, value: LegacyProtocols) {
|
||||
for protocol in SWITCHED {
|
||||
self.set(protocol, value);
|
||||
}
|
||||
self.set_all_field(value);
|
||||
}
|
||||
|
||||
/// Writes out every switch and derives the kill-all from them, so what is
|
||||
/// stored and shown never depends on how it was reached.
|
||||
fn normalize(&mut self) {
|
||||
let values: Vec<_> = SWITCHED.iter().map(|p| self.switch(p)).collect();
|
||||
for (protocol, value) in SWITCHED.iter().zip(values) {
|
||||
self.set(protocol, value);
|
||||
}
|
||||
let all = if self.all_off() {
|
||||
LegacyProtocols::Disabled
|
||||
} else {
|
||||
LegacyProtocols::Enabled
|
||||
};
|
||||
self.set_all_field(all);
|
||||
}
|
||||
|
||||
/// The protocols that are off.
|
||||
fn off(&self) -> Vec<&'static str> {
|
||||
SWITCHED
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|p| self.is_off(p))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! switches {
|
||||
($t:ty) => {
|
||||
impl Switches for $t {
|
||||
fn all(&self) -> LegacyProtocols {
|
||||
self.legacy_protocols
|
||||
}
|
||||
fn slot(&self, protocol: &str) -> Option<&Option<LegacyProtocols>> {
|
||||
match protocol {
|
||||
"imap" => Some(&self.imap),
|
||||
"pop3" => Some(&self.pop3),
|
||||
"manageSieve" => Some(&self.manage_sieve),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn slot_mut(&mut self, protocol: &str) -> Option<&mut Option<LegacyProtocols>> {
|
||||
match protocol {
|
||||
"imap" => Some(&mut self.imap),
|
||||
"pop3" => Some(&mut self.pop3),
|
||||
"manageSieve" => Some(&mut self.manage_sieve),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn set_all_field(&mut self, value: LegacyProtocols) {
|
||||
self.legacy_protocols = value;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
pub(crate) use switches;
|
||||
|
||||
switches!(ProtocolPolicy);
|
||||
|
||||
impl ProtocolPolicy {
|
||||
/// Whether a listener of this protocol and these ports is one the
|
||||
/// switches close. A listener bound to port 25 is inbound whatever its
|
||||
/// name, and any other SMTP listener counts as submission (LP-3), closed
|
||||
/// only with all three off and `closeSubmission`.
|
||||
pub fn closes(&self, protocol: &str, ports: &[u16]) -> bool {
|
||||
// The lock is checked first and answers for every caller, so no
|
||||
// request phrasing can reach past it (LP-21).
|
||||
if is_locked(protocol) {
|
||||
return false;
|
||||
}
|
||||
if LEGACY_PROTOCOLS.contains(&protocol) {
|
||||
return true;
|
||||
return self.is_off(protocol);
|
||||
}
|
||||
protocol.eq_ignore_ascii_case("smtp")
|
||||
&& self.all_off()
|
||||
&& self.close_submission
|
||||
&& !ports.contains(&INBOUND_SMTP_PORT)
|
||||
}
|
||||
@@ -258,7 +407,10 @@ mod tests {
|
||||
"an unset closeSubmission reads as the default, true"
|
||||
);
|
||||
|
||||
let json = serde_json::to_value(&policy).unwrap();
|
||||
// As shown: normalized, every switch written out.
|
||||
let mut shown = policy.clone();
|
||||
shown.normalize();
|
||||
let json = serde_json::to_value(&shown).unwrap();
|
||||
for property in PROPERTIES {
|
||||
assert!(json.get(property).is_some(), "{property}");
|
||||
}
|
||||
@@ -410,6 +562,72 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A policy stored before the per-protocol switches reads as all three
|
||||
/// at its one value.
|
||||
#[test]
|
||||
fn an_old_policy_reads_as_all_three() {
|
||||
let old: ProtocolPolicy =
|
||||
serde_json::from_str(r#"{"legacyProtocols": "disabled"}"#).unwrap();
|
||||
for p in SWITCHED {
|
||||
assert!(old.is_off(p), "{p}");
|
||||
}
|
||||
assert!(old.all_off() && old.is_off(SUBMISSION));
|
||||
let old: ProtocolPolicy =
|
||||
serde_json::from_str(r#"{"legacyProtocols": "enabled"}"#).unwrap();
|
||||
assert!(old.off().is_empty() && !old.is_off(SUBMISSION));
|
||||
}
|
||||
|
||||
/// One protocol off closes only its listeners, and leaves sending alone.
|
||||
#[test]
|
||||
fn one_protocol_off() {
|
||||
let mut policy = ProtocolPolicy::default();
|
||||
policy.set("pop3", LegacyProtocols::Disabled);
|
||||
policy.normalize();
|
||||
assert!(policy.closes("pop3", &[995]));
|
||||
assert!(!policy.closes("imap", &[993]));
|
||||
assert!(!policy.closes("manageSieve", &[4190]));
|
||||
assert!(!policy.is_off(SUBMISSION), "sending goes on");
|
||||
assert_eq!(policy.legacy_protocols, LegacyProtocols::Enabled);
|
||||
assert_eq!(policy.off(), vec!["pop3"]);
|
||||
let json = serde_json::to_value(&policy).unwrap();
|
||||
assert_eq!(json["pop3"], "disabled");
|
||||
assert_eq!(json["imap"], "enabled");
|
||||
}
|
||||
|
||||
/// Turning the three off one at a time is the kill-all, and the kill-all
|
||||
/// back on turns all three on.
|
||||
#[test]
|
||||
fn the_kill_all_is_all_three() {
|
||||
let mut policy = ProtocolPolicy::default();
|
||||
for p in SWITCHED {
|
||||
policy.set(p, LegacyProtocols::Disabled);
|
||||
}
|
||||
policy.normalize();
|
||||
assert!(policy.legacy_protocols.is_disabled());
|
||||
assert!(policy.is_off(SUBMISSION));
|
||||
|
||||
policy.set_all(LegacyProtocols::Enabled);
|
||||
policy.normalize();
|
||||
assert!(policy.off().is_empty());
|
||||
assert!(!policy.legacy_protocols.is_disabled());
|
||||
|
||||
// The kill-all then one back on: no longer all off.
|
||||
policy.set_all(LegacyProtocols::Disabled);
|
||||
policy.set("imap", LegacyProtocols::Enabled);
|
||||
policy.normalize();
|
||||
assert!(!policy.legacy_protocols.is_disabled());
|
||||
assert_eq!(policy.off(), vec!["pop3", "manageSieve"]);
|
||||
}
|
||||
|
||||
/// Protocols without a switch are never off.
|
||||
#[test]
|
||||
fn unswitched_protocols_are_on() {
|
||||
let policy = disabled();
|
||||
for p in ["smtp", "http", "lmtp", "jmap"] {
|
||||
assert!(!policy.is_off(p), "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A saved listener with no id is refused, naming the property.
|
||||
#[test]
|
||||
fn a_nameless_saved_listener_is_refused() {
|
||||
|
||||
@@ -9,12 +9,18 @@
|
||||
//! the tenant id in the fork's subspace; a tenant with nothing stored has
|
||||
//! legacy protocols on.
|
||||
//!
|
||||
//! A tenant has the same three switches as the server (IMAP, POP3,
|
||||
//! ManageSieve) and the same kill-all; a protocol off server-wide is off for
|
||||
//! every tenant whatever the tenant's own switch says.
|
||||
//!
|
||||
//! A tenant's switch closes no port -- other tenants share them (LP-13). It
|
||||
//! refuses sign-in on the tenant's domains, and keeps client configuration
|
||||
//! for them from offering what's refused. That is all it is: one fact per
|
||||
//! tenant, easy to turn back, touching no listener, role or permission.
|
||||
|
||||
use crate::security::protocol_policy::{LegacyProtocols, ProtocolPolicy};
|
||||
use crate::security::protocol_policy::{
|
||||
LegacyProtocols, ProtocolPolicy, SUBMISSION, SWITCHED, Switches, switches,
|
||||
};
|
||||
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
|
||||
use store::{
|
||||
Deserialize, SUBSPACE_INBUXA, Store, ValueKey,
|
||||
@@ -26,24 +32,92 @@ use trc::AddContext;
|
||||
#[derive(Debug, Clone, PartialEq, Default, SerdeSerialize, SerdeDeserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct TenantProtocolPolicy {
|
||||
/// The switch itself.
|
||||
/// The kill-all, as on the server's policy.
|
||||
pub legacy_protocols: LegacyProtocols,
|
||||
/// IMAP's switch. Unset reads as `legacy_protocols`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub imap: Option<LegacyProtocols>,
|
||||
/// POP3's switch. Unset reads as `legacy_protocols`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pop3: Option<LegacyProtocols>,
|
||||
/// ManageSieve's switch. Unset reads as `legacy_protocols`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub manage_sieve: Option<LegacyProtocols>,
|
||||
/// When it last changed, in milliseconds since the epoch.
|
||||
pub changed_at: Option<u64>,
|
||||
/// The account that last changed it.
|
||||
pub changed_by: Option<String>,
|
||||
}
|
||||
|
||||
/// Why a tenant's switch can't be set this way, if it can't (LP-9).
|
||||
switches!(TenantProtocolPolicy);
|
||||
|
||||
/// Why a tenant's switches can't be set this way, if they can't (LP-9).
|
||||
///
|
||||
/// A tenant can always turn legacy protocols off for itself. It can turn
|
||||
/// them back on only while the server has them on: server off means off for
|
||||
/// everyone.
|
||||
pub fn refusal(server: &ProtocolPolicy, requested: LegacyProtocols) -> Option<&'static str> {
|
||||
(server.legacy_protocols.is_disabled() && !requested.is_disabled()).then_some(
|
||||
"Legacy mail protocols are off for the whole server (inbuxa:ProtocolPolicy), \
|
||||
so they can't be turned back on for one organization.",
|
||||
/// A tenant can always turn a protocol off for itself. It can turn one on
|
||||
/// only while the server has it on: server off means off for everyone.
|
||||
/// `turned_on` is what the request sets to `enabled`, by protocol name.
|
||||
pub fn refusal(server: &ProtocolPolicy, turned_on: &[&str]) -> Option<String> {
|
||||
let blocked: Vec<&str> = turned_on
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|protocol| server.is_off(protocol))
|
||||
.collect();
|
||||
(!blocked.is_empty()).then(|| {
|
||||
format!(
|
||||
"{} off for the whole server (inbuxa:ProtocolPolicy), so {} can't be turned \
|
||||
back on for one organization.",
|
||||
names(&blocked),
|
||||
if blocked.len() == 1 { "it" } else { "they" }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Protocol names as people read them: "IMAP and POP3 are", "POP3 is".
|
||||
fn names(protocols: &[&str]) -> String {
|
||||
let named: Vec<&str> = protocols
|
||||
.iter()
|
||||
.map(|p| match *p {
|
||||
"imap" => "IMAP",
|
||||
"pop3" => "POP3",
|
||||
"manageSieve" => "ManageSieve",
|
||||
other => other,
|
||||
})
|
||||
.collect();
|
||||
let list = match named.as_slice() {
|
||||
[one] => one.to_string(),
|
||||
[rest @ .., last] => format!("{} and {last}", rest.join(", ")),
|
||||
[] => String::new(),
|
||||
};
|
||||
format!("{list} {}", if named.len() == 1 { "is" } else { "are" })
|
||||
}
|
||||
|
||||
/// Whose switch turns a protocol off, if any.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OffBy {
|
||||
Server,
|
||||
Tenant,
|
||||
}
|
||||
|
||||
/// Whether this protocol is off for an account or domain, and by whose
|
||||
/// switch: the server's first (LP-6), then the tenant's (LP-10). Submission
|
||||
/// is off when all three protocols are, counting both switches together.
|
||||
pub fn off_by(
|
||||
server: &ProtocolPolicy,
|
||||
tenant: Option<&TenantProtocolPolicy>,
|
||||
protocol: &str,
|
||||
) -> Option<OffBy> {
|
||||
if server.is_off(protocol) {
|
||||
return Some(OffBy::Server);
|
||||
}
|
||||
let tenant = tenant?;
|
||||
let off = if protocol == SUBMISSION {
|
||||
SWITCHED
|
||||
.iter()
|
||||
.all(|p| server.is_off(p) || tenant.is_off(p))
|
||||
} else {
|
||||
tenant.is_off(protocol)
|
||||
};
|
||||
off.then_some(OffBy::Tenant)
|
||||
}
|
||||
|
||||
fn key(tenant_id: u32) -> ValueClass {
|
||||
@@ -115,6 +189,15 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn tenant_off(protocols: &[&str]) -> TenantProtocolPolicy {
|
||||
let mut policy = TenantProtocolPolicy::default();
|
||||
for p in protocols {
|
||||
policy.set(p, LegacyProtocols::Disabled);
|
||||
}
|
||||
policy.normalize();
|
||||
policy
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tenant_starts_with_legacy_protocols_on() {
|
||||
assert!(
|
||||
@@ -127,20 +210,59 @@ mod tests {
|
||||
#[test]
|
||||
fn a_tenant_can_always_turn_them_off() {
|
||||
for s in [LegacyProtocols::Enabled, LegacyProtocols::Disabled] {
|
||||
assert_eq!(refusal(&server(s), LegacyProtocols::Disabled), None);
|
||||
assert_eq!(refusal(&server(s), &[]), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tenant_can_turn_them_on_only_while_the_server_has_them_on() {
|
||||
// LP-9, acceptance test 9.
|
||||
assert_eq!(
|
||||
refusal(&server(LegacyProtocols::Enabled), LegacyProtocols::Enabled),
|
||||
None
|
||||
);
|
||||
let why =
|
||||
refusal(&server(LegacyProtocols::Disabled), LegacyProtocols::Enabled).expect("refused");
|
||||
assert_eq!(refusal(&server(LegacyProtocols::Enabled), SWITCHED), None);
|
||||
let why = refusal(&server(LegacyProtocols::Disabled), SWITCHED).expect("refused");
|
||||
assert!(why.contains("inbuxa:ProtocolPolicy"), "{why}");
|
||||
assert!(
|
||||
why.starts_with("IMAP, POP3 and ManageSieve are off"),
|
||||
"{why}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tenant_can_turn_on_what_the_server_allows() {
|
||||
// The server has only POP3 off: IMAP may come back, POP3 may not.
|
||||
let mut s = ProtocolPolicy::default();
|
||||
s.set("pop3", LegacyProtocols::Disabled);
|
||||
assert_eq!(refusal(&s, &["imap"]), None);
|
||||
let why = refusal(&s, &["imap", "pop3"]).expect("refused");
|
||||
assert!(why.starts_with("POP3 is off"), "{why}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whose_switch_turns_a_protocol_off() {
|
||||
let mut s = ProtocolPolicy::default();
|
||||
s.set("pop3", LegacyProtocols::Disabled);
|
||||
let t = tenant_off(&["imap"]);
|
||||
assert_eq!(off_by(&s, Some(&t), "pop3"), Some(OffBy::Server));
|
||||
assert_eq!(off_by(&s, Some(&t), "imap"), Some(OffBy::Tenant));
|
||||
assert_eq!(off_by(&s, Some(&t), "manageSieve"), None);
|
||||
assert_eq!(off_by(&s, None, "imap"), None);
|
||||
// Sending goes on while any protocol is still allowed.
|
||||
assert_eq!(off_by(&s, Some(&t), SUBMISSION), None);
|
||||
// Between them, all three off: submission follows (LP-6, LP-10).
|
||||
let t = tenant_off(&["imap", "manageSieve"]);
|
||||
assert_eq!(off_by(&s, Some(&t), SUBMISSION), Some(OffBy::Tenant));
|
||||
assert_eq!(
|
||||
off_by(&server(LegacyProtocols::Disabled), None, SUBMISSION),
|
||||
Some(OffBy::Server)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_old_tenant_policy_reads_as_all_three() {
|
||||
let Json(old) = Json::deserialize(br#"{"legacyProtocols":"disabled"}"#).unwrap();
|
||||
for p in SWITCHED {
|
||||
assert!(old.is_off(p), "{p}");
|
||||
}
|
||||
assert!(old.is_off(SUBMISSION));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -158,6 +280,7 @@ mod tests {
|
||||
legacy_protocols: LegacyProtocols::Disabled,
|
||||
changed_at: Some(1),
|
||||
changed_by: Some("b".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let Json(back) = Json::deserialize(&serde_json::to_vec(&policy).unwrap()).unwrap();
|
||||
assert_eq!(back, policy);
|
||||
|
||||
@@ -23,8 +23,13 @@ pub struct ProtocolPolicy;
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum ProtocolPolicyProperty {
|
||||
Id,
|
||||
/// The switch: `enabled` or `disabled`.
|
||||
/// The kill-all: `enabled` or `disabled`; reads `disabled` when all
|
||||
/// three protocols are off, and sets all three.
|
||||
LegacyProtocols,
|
||||
/// Each protocol's own switch: `enabled` or `disabled`.
|
||||
Imap,
|
||||
Pop3,
|
||||
ManageSieve,
|
||||
/// Whether submission closes with it. Forced false while SMTP is locked.
|
||||
CloseSubmission,
|
||||
/// Server-set: the listeners taken away, for LP-5.
|
||||
@@ -56,6 +61,9 @@ impl Property for ProtocolPolicyProperty {
|
||||
match self {
|
||||
ProtocolPolicyProperty::Id => "id",
|
||||
ProtocolPolicyProperty::LegacyProtocols => "legacyProtocols",
|
||||
ProtocolPolicyProperty::Imap => "imap",
|
||||
ProtocolPolicyProperty::Pop3 => "pop3",
|
||||
ProtocolPolicyProperty::ManageSieve => "manageSieve",
|
||||
ProtocolPolicyProperty::CloseSubmission => "closeSubmission",
|
||||
ProtocolPolicyProperty::SavedListeners => "savedListeners",
|
||||
ProtocolPolicyProperty::ChangedAt => "changedAt",
|
||||
@@ -73,6 +81,9 @@ impl ProtocolPolicyProperty {
|
||||
hashify::tiny_map!(value.as_bytes(),
|
||||
b"id" => ProtocolPolicyProperty::Id,
|
||||
b"legacyProtocols" => ProtocolPolicyProperty::LegacyProtocols,
|
||||
b"imap" => ProtocolPolicyProperty::Imap,
|
||||
b"pop3" => ProtocolPolicyProperty::Pop3,
|
||||
b"manageSieve" => ProtocolPolicyProperty::ManageSieve,
|
||||
b"closeSubmission" => ProtocolPolicyProperty::CloseSubmission,
|
||||
b"savedListeners" => ProtocolPolicyProperty::SavedListeners,
|
||||
b"changedAt" => ProtocolPolicyProperty::ChangedAt,
|
||||
|
||||
@@ -24,8 +24,13 @@ pub enum TenantProtocolPolicyProperty {
|
||||
Id,
|
||||
/// Server-set: the tenant this is the switch of.
|
||||
TenantId,
|
||||
/// The switch: `enabled` or `disabled`.
|
||||
/// The kill-all: `enabled` or `disabled`; reads `disabled` when all
|
||||
/// three protocols are off, and sets all three.
|
||||
LegacyProtocols,
|
||||
/// Each protocol's own switch: `enabled` or `disabled`.
|
||||
Imap,
|
||||
Pop3,
|
||||
ManageSieve,
|
||||
ChangedAt,
|
||||
ChangedBy,
|
||||
/// Server-set: who signed in over a legacy protocol in the last 30
|
||||
@@ -48,6 +53,9 @@ impl Property for TenantProtocolPolicyProperty {
|
||||
TenantProtocolPolicyProperty::Id => "id",
|
||||
TenantProtocolPolicyProperty::TenantId => "tenantId",
|
||||
TenantProtocolPolicyProperty::LegacyProtocols => "legacyProtocols",
|
||||
TenantProtocolPolicyProperty::Imap => "imap",
|
||||
TenantProtocolPolicyProperty::Pop3 => "pop3",
|
||||
TenantProtocolPolicyProperty::ManageSieve => "manageSieve",
|
||||
TenantProtocolPolicyProperty::ChangedAt => "changedAt",
|
||||
TenantProtocolPolicyProperty::ChangedBy => "changedBy",
|
||||
TenantProtocolPolicyProperty::RecentLegacyUse => "recentLegacyUse",
|
||||
@@ -62,6 +70,9 @@ impl TenantProtocolPolicyProperty {
|
||||
b"id" => TenantProtocolPolicyProperty::Id,
|
||||
b"tenantId" => TenantProtocolPolicyProperty::TenantId,
|
||||
b"legacyProtocols" => TenantProtocolPolicyProperty::LegacyProtocols,
|
||||
b"imap" => TenantProtocolPolicyProperty::Imap,
|
||||
b"pop3" => TenantProtocolPolicyProperty::Pop3,
|
||||
b"manageSieve" => TenantProtocolPolicyProperty::ManageSieve,
|
||||
b"changedAt" => TenantProtocolPolicyProperty::ChangedAt,
|
||||
b"changedBy" => TenantProtocolPolicyProperty::ChangedBy,
|
||||
b"recentLegacyUse" => TenantProtocolPolicyProperty::RecentLegacyUse,
|
||||
|
||||
@@ -169,6 +169,11 @@ pub struct InbuxaAccountCapabilities {
|
||||
/// (legacy-protocols spec, Interfaces; LP-19).
|
||||
#[serde(rename(serialize = "legacyProtocols"))]
|
||||
pub legacy_protocols: &'static str,
|
||||
/// The legacy protocols still allowed for the principal, each the
|
||||
/// stricter of the two switches: `imap`, `pop3`, `manageSieve`,
|
||||
/// `submission` (legacy-protocols spec, one switch per protocol).
|
||||
#[serde(rename(serialize = "legacyAllowed"))]
|
||||
pub legacy_allowed: Vec<&'static str>,
|
||||
/// Whether the principal may use "Explain this" now: it holds
|
||||
/// `sysAiExplain`, is server-level, and a model resolves (ai-explain
|
||||
/// spec, EX-1 to EX-4).
|
||||
|
||||
@@ -66,12 +66,16 @@ impl SessionHandler for Server {
|
||||
Capability::Inbuxa,
|
||||
Capabilities::Empty(EmptyCapabilities::default()),
|
||||
);
|
||||
// inbuxa: legacy-protocols, Interfaces: whichever switch is stricter
|
||||
let legacy_protocols = if self.legacy_protocols_off_for_account(access_token).await? {
|
||||
// inbuxa: legacy-protocols, Interfaces: whichever switch is stricter,
|
||||
// per protocol. `legacyProtocols` stays for older webmail builds:
|
||||
// `disabled` only when every protocol is off.
|
||||
let legacy_off = self.legacy_off_for_account(access_token).await?;
|
||||
let legacy_protocols = if legacy_off.all() {
|
||||
"disabled"
|
||||
} else {
|
||||
"enabled"
|
||||
};
|
||||
let legacy_allowed = legacy_off.allowed();
|
||||
// inbuxa: ai-explain, EX-1 to EX-4: whether Explain can be offered
|
||||
let ai_explain = access_token.has_permission(Permission::SysAiExplain)
|
||||
&& access_token.tenant_id().is_none()
|
||||
@@ -81,6 +85,7 @@ impl SessionHandler for Server {
|
||||
Capabilities::Inbuxa(InbuxaAccountCapabilities {
|
||||
logo,
|
||||
legacy_protocols,
|
||||
legacy_allowed,
|
||||
ai_explain,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -376,7 +376,11 @@ async fn full_name(server: &Server, object: &str, value: &Value, name: Option<St
|
||||
}
|
||||
|
||||
async fn fork_current(server: &Server, object: &str, id: &MaybeInvalid<Id>) -> Option<Value> {
|
||||
use inbuxa_features::{ai::limits, audit::log, security};
|
||||
use inbuxa_features::{
|
||||
ai::limits,
|
||||
audit::log,
|
||||
security::{self, protocol_policy::Switches},
|
||||
};
|
||||
let data = server.store();
|
||||
match object {
|
||||
"inbuxa:AuditSettings" => log::settings(data)
|
||||
@@ -387,10 +391,17 @@ async fn fork_current(server: &Server, object: &str, id: &MaybeInvalid<Id>) -> O
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|limits| serde_json::to_value(limits).ok()),
|
||||
"inbuxa:ProtocolPolicy" => security::protocol_policy::get(data)
|
||||
// Normalized, so every switch reads before and after, even from a
|
||||
// policy stored before the per-protocol switches
|
||||
"inbuxa:ProtocolPolicy" => {
|
||||
security::protocol_policy::get(data)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|policy| serde_json::to_value(policy).ok()),
|
||||
.and_then(|mut policy| {
|
||||
policy.normalize();
|
||||
serde_json::to_value(policy).ok()
|
||||
})
|
||||
}
|
||||
// LH-1: a hold as the API shows it, so a change reads before/after
|
||||
"inbuxa:LegalHold" => match id {
|
||||
MaybeInvalid::Value(id) => {
|
||||
@@ -424,7 +435,10 @@ async fn fork_current(server: &Server, object: &str, id: &MaybeInvalid<Id>) -> O
|
||||
security::tenant_protocol_policy::get(data, id.document_id())
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|policy| serde_json::to_value(policy).ok())
|
||||
.and_then(|mut policy| {
|
||||
policy.normalize();
|
||||
serde_json::to_value(policy).ok()
|
||||
})
|
||||
}
|
||||
MaybeInvalid::Invalid(_) => None,
|
||||
},
|
||||
|
||||
@@ -26,7 +26,9 @@ use common::{
|
||||
};
|
||||
use inbuxa_features::security::{
|
||||
listeners,
|
||||
protocol_policy::{LOCKED_PROTOCOLS, LegacyProtocols, ProtocolPolicy as Policy, SavedListener},
|
||||
protocol_policy::{
|
||||
LOCKED_PROTOCOLS, LegacyProtocols, ProtocolPolicy as Policy, SavedListener, Switches,
|
||||
},
|
||||
};
|
||||
use jmap_proto::{
|
||||
error::set::SetError,
|
||||
@@ -48,6 +50,9 @@ type PValue = Value<'static, P, ProtocolPolicyValue>;
|
||||
const ALL: &[P] = &[
|
||||
P::Id,
|
||||
P::LegacyProtocols,
|
||||
P::Imap,
|
||||
P::Pop3,
|
||||
P::ManageSieve,
|
||||
P::CloseSubmission,
|
||||
P::SavedListeners,
|
||||
P::ChangedAt,
|
||||
@@ -91,22 +96,49 @@ fn listener_value(listener: &SavedListener) -> PValue {
|
||||
Value::Object(out)
|
||||
}
|
||||
|
||||
/// A switch as JMAP spells it.
|
||||
pub(crate) fn switch_str(value: LegacyProtocols) -> &'static str {
|
||||
match value {
|
||||
LegacyProtocols::Enabled => "enabled",
|
||||
LegacyProtocols::Disabled => "disabled",
|
||||
}
|
||||
}
|
||||
|
||||
/// A switch from JMAP.
|
||||
pub(crate) fn parse_switch(value: Option<&str>) -> Result<LegacyProtocols, String> {
|
||||
match value {
|
||||
Some("enabled") => Ok(LegacyProtocols::Enabled),
|
||||
Some("disabled") => Ok(LegacyProtocols::Disabled),
|
||||
_ => Err(r#"must be "enabled" or "disabled""#.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The JMAP name of a per-protocol switch property.
|
||||
pub(crate) fn switch_name(property: &P) -> Option<&'static str> {
|
||||
match property {
|
||||
P::Imap => Some("imap"),
|
||||
P::Pop3 => Some("pop3"),
|
||||
P::ManageSieve => Some("manageSieve"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_value(
|
||||
policy: &Policy,
|
||||
would_close: &[SavedListener],
|
||||
recent: &[RecentUse],
|
||||
properties: &[P],
|
||||
) -> PValue {
|
||||
let mut policy = policy.clone();
|
||||
policy.normalize();
|
||||
let policy = &policy;
|
||||
let mut out = Map::with_capacity(properties.len());
|
||||
for property in properties {
|
||||
let value = match property {
|
||||
P::Id => Value::Element(ProtocolPolicyValue::Id(Id::singleton())),
|
||||
P::LegacyProtocols => Value::Str(
|
||||
match policy.legacy_protocols {
|
||||
LegacyProtocols::Enabled => "enabled",
|
||||
LegacyProtocols::Disabled => "disabled",
|
||||
}
|
||||
.into(),
|
||||
P::LegacyProtocols => Value::Str(switch_str(policy.legacy_protocols).into()),
|
||||
P::Imap | P::Pop3 | P::ManageSieve => Value::Str(
|
||||
switch_str(policy.switch(switch_name(property).unwrap_or_default())).into(),
|
||||
),
|
||||
P::CloseSubmission => Value::Bool(policy.close_submission),
|
||||
P::SavedListeners => Value::Array(
|
||||
@@ -176,10 +208,11 @@ where
|
||||
)
|
||||
}
|
||||
|
||||
/// The listeners turning the switch on would close, whatever it is now.
|
||||
/// The listeners turning every protocol off would close, whatever the switches
|
||||
/// are now; each names its protocol, so the console shows one protocol's.
|
||||
async fn would_close(server: &Server, policy: &Policy) -> trc::Result<Vec<SavedListener>> {
|
||||
let mut hypothetical = policy.clone();
|
||||
hypothetical.legacy_protocols = LegacyProtocols::Disabled;
|
||||
hypothetical.set_all(LegacyProtocols::Disabled);
|
||||
hypothetical.apply_locks();
|
||||
listeners::would_close(server.registry(), &hypothetical).await
|
||||
}
|
||||
@@ -238,12 +271,12 @@ fn apply(
|
||||
value: &Value<'_, P, ProtocolPolicyValue>,
|
||||
) -> Result<(), String> {
|
||||
match property {
|
||||
P::LegacyProtocols => {
|
||||
policy.legacy_protocols = match value.as_str().as_deref() {
|
||||
Some("enabled") => LegacyProtocols::Enabled,
|
||||
Some("disabled") => LegacyProtocols::Disabled,
|
||||
_ => return Err(r#"must be "enabled" or "disabled""#.to_string()),
|
||||
}
|
||||
// The kill-all sets all three; a protocol named in the same /set is
|
||||
// applied after it (see `set`), so it wins.
|
||||
P::LegacyProtocols => policy.set_all(parse_switch(value.as_str().as_deref())?),
|
||||
P::Imap | P::Pop3 | P::ManageSieve => {
|
||||
let value = parse_switch(value.as_str().as_deref())?;
|
||||
policy.set(switch_name(property).unwrap_or_default(), value);
|
||||
}
|
||||
P::CloseSubmission => {
|
||||
policy.close_submission = value
|
||||
@@ -262,7 +295,13 @@ fn apply(
|
||||
/// Puts a property back to its default (a `null` in `/set`).
|
||||
fn reset(policy: &mut Policy, property: &P, defaults: &Policy) -> Result<(), String> {
|
||||
match property {
|
||||
P::LegacyProtocols => policy.legacy_protocols = defaults.legacy_protocols,
|
||||
P::LegacyProtocols => policy.set_all(defaults.legacy_protocols),
|
||||
P::Imap | P::Pop3 | P::ManageSieve => {
|
||||
policy.set(
|
||||
switch_name(property).unwrap_or_default(),
|
||||
LegacyProtocols::Enabled,
|
||||
);
|
||||
}
|
||||
P::CloseSubmission => policy.close_submission = defaults.close_submission,
|
||||
P::Id => return Err("is immutable".to_string()),
|
||||
other if other.is_server_set() => return Err("is set by the server".to_string()),
|
||||
@@ -312,10 +351,14 @@ pub async fn set(
|
||||
}
|
||||
|
||||
let mut policy = server.protocol_policy().await?;
|
||||
policy.normalize();
|
||||
let defaults = Policy::default();
|
||||
let mut error = None;
|
||||
|
||||
for (key, value) in value.into_expanded_object() {
|
||||
// The kill-all first, so a protocol named beside it overrides it.
|
||||
let mut entries: Vec<_> = value.into_expanded_object().collect();
|
||||
entries.sort_by_key(|(key, _)| !matches!(key, Key::Property(P::LegacyProtocols)));
|
||||
for (key, value) in entries {
|
||||
let Key::Property(property) = &key else {
|
||||
error = Some(SetError::invalid_properties().with_property(key.into_owned()));
|
||||
break;
|
||||
@@ -393,21 +436,30 @@ fn listener_refusal(policy: &Policy, listener: &NetworkListener) -> Option<(Prop
|
||||
let protocol = listeners::protocol_name(listener.protocol);
|
||||
// A submission listener closes because of its port, not its protocol
|
||||
// (LP-3), so the port is what would have to change.
|
||||
let property = if protocol == "smtp" {
|
||||
Property::Bind
|
||||
let (property, what) = if protocol == "smtp" {
|
||||
(Property::Bind, "Legacy mail protocols are".to_string())
|
||||
} else {
|
||||
Property::Protocol
|
||||
(Property::Protocol, format!("{} is", display_name(protocol)))
|
||||
};
|
||||
Some((
|
||||
property,
|
||||
format!(
|
||||
"Legacy mail protocols are off (inbuxa:ProtocolPolicy), and this {protocol} \
|
||||
listener would reopen a port the switch keeps closed. Turn legacy protocols \
|
||||
back on first."
|
||||
"{what} off (inbuxa:ProtocolPolicy), and this {protocol} listener would reopen \
|
||||
a port the switch keeps closed. Turn it back on first."
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
/// A protocol's name as people read it.
|
||||
fn display_name(protocol: &str) -> &str {
|
||||
match protocol {
|
||||
"imap" => "IMAP",
|
||||
"pop3" => "POP3",
|
||||
"manageSieve" => "ManageSieve",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -461,6 +513,36 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_protocol_off_refuses_only_its_listeners() {
|
||||
let mut policy = Policy::default();
|
||||
policy.set("pop3", LegacyProtocols::Disabled);
|
||||
policy.normalize();
|
||||
let (property, why) = listener_refusal(
|
||||
&policy,
|
||||
&listener(NetworkListenerProtocol::Pop3, "[::]:995"),
|
||||
)
|
||||
.expect("refused");
|
||||
assert_eq!(property, Property::Protocol);
|
||||
assert!(why.starts_with("POP3 is off"), "{why}");
|
||||
assert!(
|
||||
listener_refusal(
|
||||
&policy,
|
||||
&listener(NetworkListenerProtocol::Imap, "[::]:993")
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_switch_reads_and_parses_as_jmap_spells_it() {
|
||||
assert_eq!(switch_str(LegacyProtocols::Disabled), "disabled");
|
||||
assert_eq!(parse_switch(Some("enabled")), Ok(LegacyProtocols::Enabled));
|
||||
assert!(parse_switch(Some("off")).is_err());
|
||||
assert_eq!(switch_name(&P::ManageSieve), Some("manageSieve"));
|
||||
assert_eq!(switch_name(&P::CloseSubmission), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn off_still_allows_what_the_switch_never_closes() {
|
||||
// Locked (LP-21) and inbound (LP-3): the switch doesn't close them,
|
||||
|
||||
@@ -12,16 +12,22 @@
|
||||
//! with no ids answers with it, and any other id is `notFound`. At server
|
||||
//! level, `/get` with no ids answers with every tenant's.
|
||||
//!
|
||||
//! Turning it off never needs the server's leave; turning it back on is
|
||||
//! refused with `forbidden` while the server has legacy protocols off (LP-9).
|
||||
//! Each of IMAP, POP3 and ManageSieve has its own switch, and
|
||||
//! `legacyProtocols` is the kill-all, as on the server's policy. Turning one
|
||||
//! off never needs the server's leave; turning one back on is refused with
|
||||
//! `forbidden` while the server has that protocol off (LP-9).
|
||||
//! A tenant's switch closes no port (LP-13) -- sign-in and client
|
||||
//! configuration read it (LP-10, LP-14a).
|
||||
|
||||
use crate::inbuxa::protocol_policy::recent_value;
|
||||
use common::{Server, auth::AccessToken, network::legacy::RecentUse};
|
||||
use crate::inbuxa::protocol_policy::{parse_switch, recent_value, switch_str};
|
||||
use common::{
|
||||
Server,
|
||||
auth::AccessToken,
|
||||
network::legacy::{RecentUse, switches_value},
|
||||
};
|
||||
use inbuxa_features::{
|
||||
security::{
|
||||
protocol_policy::LegacyProtocols,
|
||||
protocol_policy::{LegacyProtocols, SWITCHED, Switches},
|
||||
tenant_protocol_policy::{self, TenantProtocolPolicy as Policy, refusal},
|
||||
},
|
||||
tenancy::quota::all_tenants,
|
||||
@@ -46,6 +52,9 @@ const ALL: &[P] = &[
|
||||
P::Id,
|
||||
P::TenantId,
|
||||
P::LegacyProtocols,
|
||||
P::Imap,
|
||||
P::Pop3,
|
||||
P::ManageSieve,
|
||||
P::ChangedAt,
|
||||
P::ChangedBy,
|
||||
P::RecentLegacyUse,
|
||||
@@ -60,19 +69,29 @@ async fn reachable(server: &Server, access_token: &AccessToken) -> trc::Result<V
|
||||
}
|
||||
}
|
||||
|
||||
/// The JMAP name of a per-protocol switch property.
|
||||
fn switch_name(property: &P) -> Option<&'static str> {
|
||||
match property {
|
||||
P::Imap => Some("imap"),
|
||||
P::Pop3 => Some("pop3"),
|
||||
P::ManageSieve => Some("manageSieve"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_value(tenant_id: u32, policy: &Policy, recent: &[RecentUse], properties: &[P]) -> PValue {
|
||||
let mut policy = policy.clone();
|
||||
policy.normalize();
|
||||
let policy = &policy;
|
||||
let mut out = Map::with_capacity(properties.len());
|
||||
for property in properties {
|
||||
let value = match property {
|
||||
P::Id | P::TenantId => {
|
||||
Value::Element(TenantProtocolPolicyValue::Id(Id::from(tenant_id)))
|
||||
}
|
||||
P::LegacyProtocols => Value::Str(
|
||||
match policy.legacy_protocols {
|
||||
LegacyProtocols::Enabled => "enabled",
|
||||
LegacyProtocols::Disabled => "disabled",
|
||||
}
|
||||
.into(),
|
||||
P::LegacyProtocols => Value::Str(switch_str(policy.legacy_protocols).into()),
|
||||
P::Imap | P::Pop3 | P::ManageSieve => Value::Str(
|
||||
switch_str(policy.switch(switch_name(property).unwrap_or_default())).into(),
|
||||
),
|
||||
P::ChangedAt => policy
|
||||
.changed_at
|
||||
@@ -165,29 +184,41 @@ pub async fn set(
|
||||
let data = &server.core.storage.data;
|
||||
let previous = tenant_protocol_policy::get(data, tenant_id).await?;
|
||||
let mut policy = previous.clone();
|
||||
policy.normalize();
|
||||
let mut error = None;
|
||||
for (key, value) in value.into_expanded_object() {
|
||||
// What this request sets to `enabled`, for LP-9.
|
||||
let mut turned_on: Vec<&'static str> = Vec::new();
|
||||
// The kill-all first, so a protocol named beside it overrides it.
|
||||
let mut entries: Vec<_> = value.into_expanded_object().collect();
|
||||
entries.sort_by_key(|(key, _)| !matches!(key, Key::Property(P::LegacyProtocols)));
|
||||
for (key, value) in entries {
|
||||
// `null` puts a switch back to its default, on.
|
||||
let parsed = match value {
|
||||
Value::Null => Ok(LegacyProtocols::Enabled),
|
||||
value => parse_switch(value.as_str().as_deref()),
|
||||
};
|
||||
let result = match &key {
|
||||
Key::Property(P::LegacyProtocols) => match value {
|
||||
Value::Null => {
|
||||
policy.legacy_protocols = LegacyProtocols::Enabled;
|
||||
Ok(())
|
||||
Key::Property(P::LegacyProtocols) => parsed.map(|value| {
|
||||
policy.set_all(value);
|
||||
if !value.is_disabled() {
|
||||
turned_on.extend(SWITCHED.iter().copied());
|
||||
} else {
|
||||
turned_on.clear();
|
||||
}
|
||||
value => match value.as_str().as_deref() {
|
||||
Some("enabled") => {
|
||||
policy.legacy_protocols = LegacyProtocols::Enabled;
|
||||
Ok(())
|
||||
}),
|
||||
Key::Property(property @ (P::Imap | P::Pop3 | P::ManageSieve)) => {
|
||||
parsed.map(|value| {
|
||||
let name = switch_name(property).unwrap_or_default();
|
||||
policy.set(name, value);
|
||||
turned_on.retain(|p| *p != name);
|
||||
if !value.is_disabled() {
|
||||
turned_on.push(name);
|
||||
}
|
||||
Some("disabled") => {
|
||||
policy.legacy_protocols = LegacyProtocols::Disabled;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
_ => Err(r#"must be "enabled" or "disabled""#),
|
||||
},
|
||||
},
|
||||
Key::Property(P::Id) => Err("is immutable"),
|
||||
Key::Property(_) => Err("is set by the server"),
|
||||
_ => Err("is not a property of inbuxa:TenantProtocolPolicy"),
|
||||
Key::Property(P::Id) => Err("is immutable".to_string()),
|
||||
Key::Property(_) => Err("is set by the server".to_string()),
|
||||
_ => Err("is not a property of inbuxa:TenantProtocolPolicy".to_string()),
|
||||
};
|
||||
if let Err(why) = result {
|
||||
error = Some(
|
||||
@@ -203,15 +234,18 @@ pub async fn set(
|
||||
continue;
|
||||
}
|
||||
|
||||
// LP-9: server off means off for everyone.
|
||||
if let Some(why) = refusal(&server.protocol_policy().await?, policy.legacy_protocols) {
|
||||
// LP-9: server off means off for everyone, protocol by protocol.
|
||||
if let Some(why) = refusal(&server.protocol_policy().await?, &turned_on) {
|
||||
response
|
||||
.not_updated
|
||||
.append(id, SetError::forbidden().with_description(why));
|
||||
continue;
|
||||
}
|
||||
|
||||
if policy.legacy_protocols != previous.legacy_protocols {
|
||||
policy.normalize();
|
||||
let mut before = previous;
|
||||
before.normalize();
|
||||
if policy.off() != before.off() {
|
||||
policy.changed_at = Some(store::write::now() * 1000);
|
||||
policy.changed_by = Some(Id::from(access_token.account_id()).to_string());
|
||||
tenant_protocol_policy::set(data, tenant_id, &policy).await?;
|
||||
@@ -221,11 +255,7 @@ pub async fn set(
|
||||
Security(trc::SecurityEvent::LegacyProtocolsChanged),
|
||||
Policy = "tenant",
|
||||
Id = tenant_id,
|
||||
Value = if policy.legacy_protocols.is_disabled() {
|
||||
"disabled"
|
||||
} else {
|
||||
"enabled"
|
||||
},
|
||||
Value = switches_value(&policy),
|
||||
AccountId = policy.changed_by.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* 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 common::{
|
||||
@@ -13,6 +15,8 @@ use mail_auth::{
|
||||
MX, RecordSet,
|
||||
common::resolver::ToFqdn,
|
||||
hickory_resolver::{
|
||||
TokioResolver,
|
||||
lookup::Lookup,
|
||||
net::{DnsError, NetError},
|
||||
proto::{
|
||||
dnssec::Proof,
|
||||
@@ -83,16 +87,15 @@ impl TlsaLookup for Server {
|
||||
return mail_auth::common::resolver::mock_resolve(key.as_ref());
|
||||
}
|
||||
|
||||
let mx_lookup = match self
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dnssec
|
||||
.resolver
|
||||
.mx_lookup(Name::from_str_relaxed::<&str>(key.as_ref())?)
|
||||
let (mx_lookup, forced_insecure) = match validated_lookup(
|
||||
&self.core.smtp.resolvers.dnssec.resolver,
|
||||
self.core.smtp.resolvers.dns.resolver(),
|
||||
Name::from_str_relaxed::<&str>(key.as_ref())?,
|
||||
RecordType::MX,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(mx_lookup) => mx_lookup,
|
||||
Ok(validated) => (validated.lookup, validated.insecure),
|
||||
Err(err) => {
|
||||
if let Some(denial) = NegativeAnswer::from_error(&err)
|
||||
&& denial.response_code == ResponseCode::NoError
|
||||
@@ -144,7 +147,11 @@ impl TlsaLookup for Server {
|
||||
.collect::<Arc<[MX]>>();
|
||||
let records = RecordSet {
|
||||
rrset,
|
||||
dnssec_status: dnssec_status.unwrap_or(DnssecStatus::Indeterminate),
|
||||
dnssec_status: if forced_insecure {
|
||||
DnssecStatus::Insecure
|
||||
} else {
|
||||
dnssec_status.unwrap_or(DnssecStatus::Indeterminate)
|
||||
},
|
||||
};
|
||||
|
||||
self.inner
|
||||
@@ -285,16 +292,15 @@ impl TlsaLookup for Server {
|
||||
}
|
||||
|
||||
let name = Name::from_str_relaxed::<&str>(key.as_ref())?;
|
||||
let lookup = match self
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dnssec
|
||||
.resolver
|
||||
.ipv4_lookup(name.clone())
|
||||
let (lookup, forced_insecure) = match validated_lookup(
|
||||
&self.core.smtp.resolvers.dnssec.resolver,
|
||||
self.core.smtp.resolvers.dns.resolver(),
|
||||
name.clone(),
|
||||
RecordType::A,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(lookup) => lookup,
|
||||
Ok(validated) => (validated.lookup, validated.insecure),
|
||||
Err(err) => {
|
||||
if let Some(denial) = NegativeAnswer::from_error(&err)
|
||||
&& denial.response_code == ResponseCode::NoError
|
||||
@@ -325,7 +331,11 @@ impl TlsaLookup for Server {
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Arc<[Ipv4Addr]>>(),
|
||||
dnssec_status: tlsa_base_status(&name, answers, RecordType::A),
|
||||
dnssec_status: if forced_insecure {
|
||||
DnssecStatus::Insecure
|
||||
} else {
|
||||
tlsa_base_status(&name, answers, RecordType::A)
|
||||
},
|
||||
};
|
||||
|
||||
self.inner
|
||||
@@ -363,16 +373,15 @@ impl TlsaLookup for Server {
|
||||
}
|
||||
|
||||
let name = Name::from_str_relaxed::<&str>(key.as_ref())?;
|
||||
let lookup = match self
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dnssec
|
||||
.resolver
|
||||
.ipv6_lookup(name.clone())
|
||||
let (lookup, forced_insecure) = match validated_lookup(
|
||||
&self.core.smtp.resolvers.dnssec.resolver,
|
||||
self.core.smtp.resolvers.dns.resolver(),
|
||||
name.clone(),
|
||||
RecordType::AAAA,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(lookup) => lookup,
|
||||
Ok(validated) => (validated.lookup, validated.insecure),
|
||||
Err(err) => {
|
||||
if let Some(denial) = NegativeAnswer::from_error(&err)
|
||||
&& denial.response_code == ResponseCode::NoError
|
||||
@@ -403,7 +412,11 @@ impl TlsaLookup for Server {
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Arc<[Ipv6Addr]>>(),
|
||||
dnssec_status: tlsa_base_status(&name, answers, RecordType::AAAA),
|
||||
dnssec_status: if forced_insecure {
|
||||
DnssecStatus::Insecure
|
||||
} else {
|
||||
tlsa_base_status(&name, answers, RecordType::AAAA)
|
||||
},
|
||||
};
|
||||
|
||||
self.inner
|
||||
@@ -415,6 +428,115 @@ impl TlsaLookup for Server {
|
||||
}
|
||||
}
|
||||
|
||||
// inbuxa: hickory 0.26.3 calls some valid answers bogus, and the queue then
|
||||
// retries those hosts until the message expires. Two cases seen in production:
|
||||
//
|
||||
// - A zone delegated beneath an unsigned zone, such as `l.google.com` under
|
||||
// `google.com`. To prove the delegation insecure, hickory wants an SOA
|
||||
// record in the DS reply, and public resolvers often send none.
|
||||
// - A signed CNAME to a signed name without the record type queried. Hickory
|
||||
// checks the denial of existence against the name first asked for, not the
|
||||
// target's, and rejects it.
|
||||
//
|
||||
// When hickory says bogus, check the answer again with lookups it gets right.
|
||||
// A signed CNAME is followed and the lookup repeated at its target. Otherwise
|
||||
// the name's zone and its parents are looked up, nearest first. If one
|
||||
// validates as unsigned, nothing below it can be signed, so the plain resolver
|
||||
// answers and the result is insecure. If one validates as signed first, the
|
||||
// verdict stands.
|
||||
|
||||
const MAX_BOGUS_ALIASES: usize = 8;
|
||||
|
||||
struct ValidatedLookup {
|
||||
lookup: Lookup,
|
||||
insecure: bool,
|
||||
}
|
||||
|
||||
enum BogusRecheck {
|
||||
Alias(Name),
|
||||
Insecure,
|
||||
Bogus,
|
||||
}
|
||||
|
||||
async fn validated_lookup(
|
||||
dnssec: &TokioResolver,
|
||||
plain: &TokioResolver,
|
||||
name: Name,
|
||||
record_type: RecordType,
|
||||
) -> Result<ValidatedLookup, NetError> {
|
||||
let mut query = name;
|
||||
let mut aliases = 0;
|
||||
|
||||
loop {
|
||||
let err = match dnssec.lookup(query.clone(), record_type).await {
|
||||
Ok(lookup) => {
|
||||
return Ok(ValidatedLookup {
|
||||
lookup,
|
||||
insecure: false,
|
||||
});
|
||||
}
|
||||
Err(err @ NetError::Dns(DnsError::DnssecBogus)) => err,
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
|
||||
match recheck_bogus(dnssec, &query).await {
|
||||
BogusRecheck::Alias(target) if aliases < MAX_BOGUS_ALIASES => {
|
||||
aliases += 1;
|
||||
query = target;
|
||||
}
|
||||
BogusRecheck::Insecure => {
|
||||
return plain
|
||||
.lookup(query, record_type)
|
||||
.await
|
||||
.map(|lookup| ValidatedLookup {
|
||||
lookup,
|
||||
insecure: true,
|
||||
});
|
||||
}
|
||||
BogusRecheck::Alias(_) | BogusRecheck::Bogus => return Err(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn recheck_bogus(dnssec: &TokioResolver, name: &Name) -> BogusRecheck {
|
||||
if let Ok(lookup) = dnssec.lookup(name.clone(), RecordType::CNAME).await
|
||||
&& let Some(target) = secure_alias(name, lookup.answers())
|
||||
{
|
||||
return BogusRecheck::Alias(target);
|
||||
}
|
||||
|
||||
let mut zone = name.clone();
|
||||
while !zone.is_root() {
|
||||
if let Ok(lookup) = dnssec.lookup(zone.clone(), RecordType::SOA).await {
|
||||
match apex_status(&zone, lookup.answers()) {
|
||||
Some(DnssecStatus::Insecure) => return BogusRecheck::Insecure,
|
||||
Some(DnssecStatus::Secure) => return BogusRecheck::Bogus,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
zone = zone.base_name();
|
||||
}
|
||||
|
||||
BogusRecheck::Bogus
|
||||
}
|
||||
|
||||
fn secure_alias(query: &Name, answers: &[Record]) -> Option<Name> {
|
||||
answers.iter().find_map(|record| match &record.data {
|
||||
RData::CNAME(target) if &record.name == query && record.proof.is_secure() => {
|
||||
Some(target.0.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn apex_status(zone: &Name, answers: &[Record]) -> Option<DnssecStatus> {
|
||||
answers
|
||||
.iter()
|
||||
.filter(|record| record.record_type() == RecordType::SOA && &record.name == zone)
|
||||
.map(|record| proof_to_dnssec_status(record.proof))
|
||||
.reduce(least_secure)
|
||||
}
|
||||
|
||||
struct NegativeAnswer {
|
||||
response_code: ResponseCode,
|
||||
dnssec_status: DnssecStatus,
|
||||
@@ -511,7 +633,7 @@ pub(crate) fn least_secure(a: DnssecStatus, b: DnssecStatus) -> DnssecStatus {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use mail_auth::hickory_resolver::proto::rr::rdata::{A, CNAME};
|
||||
use mail_auth::hickory_resolver::proto::rr::rdata::{A, CNAME, SOA};
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
fn name(value: &str) -> Name {
|
||||
@@ -624,4 +746,127 @@ mod tests {
|
||||
DnssecStatus::Insecure
|
||||
);
|
||||
}
|
||||
|
||||
fn soa(owner: &str, proof: Proof) -> Record {
|
||||
let mut record = Record::from_rdata(
|
||||
name(owner),
|
||||
3600,
|
||||
RData::SOA(SOA::new(
|
||||
name("ns1.example.org."),
|
||||
name("hostmaster.example.org."),
|
||||
1,
|
||||
900,
|
||||
900,
|
||||
1800,
|
||||
60,
|
||||
)),
|
||||
);
|
||||
record.proof = proof;
|
||||
record
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secure_alias_follows_signed_cname() {
|
||||
assert_eq!(
|
||||
secure_alias(
|
||||
&name("mail.example.org."),
|
||||
&[alias("mail.example.org.", "mx.example.net.", Proof::Secure)]
|
||||
),
|
||||
Some(name("mx.example.net."))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secure_alias_ignores_unsigned_or_other_cname() {
|
||||
let query = name("mail.example.org.");
|
||||
|
||||
assert_eq!(
|
||||
secure_alias(
|
||||
&query,
|
||||
&[alias(
|
||||
"mail.example.org.",
|
||||
"mx.example.net.",
|
||||
Proof::Insecure
|
||||
)]
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
secure_alias(
|
||||
&query,
|
||||
&[alias(
|
||||
"other.example.org.",
|
||||
"mx.example.net.",
|
||||
Proof::Secure
|
||||
)]
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apex_status_reads_the_zone_soa() {
|
||||
let zone = name("example.com.");
|
||||
|
||||
for (proof, expected) in [
|
||||
(Proof::Secure, Some(DnssecStatus::Secure)),
|
||||
(Proof::Insecure, Some(DnssecStatus::Insecure)),
|
||||
(Proof::Bogus, Some(DnssecStatus::Bogus)),
|
||||
] {
|
||||
assert_eq!(
|
||||
apex_status(&zone, &[soa("example.com.", proof)]),
|
||||
expected,
|
||||
"proof {proof}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apex_status_ignores_other_records() {
|
||||
assert_eq!(
|
||||
apex_status(
|
||||
&name("example.com."),
|
||||
&[
|
||||
soa("sub.example.com.", Proof::Insecure),
|
||||
address("example.com.", Proof::Insecure),
|
||||
]
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
// Needs the network: a signed MX pointing into a zone delegated beneath an
|
||||
// unsigned one. Run with `--ignored` to check a hickory upgrade.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn validated_lookup_proves_delegation_below_unsigned_zone() {
|
||||
use mail_auth::hickory_resolver::{
|
||||
config::{CLOUDFLARE, ResolverConfig, ResolverOpts},
|
||||
net::runtime::TokioRuntimeProvider,
|
||||
};
|
||||
|
||||
let build = |validate: bool| {
|
||||
// Same options as the server's DNSSEC resolver; hickory fails
|
||||
// validation with concurrent requests.
|
||||
let mut opts = ResolverOpts::default();
|
||||
opts.validate = validate;
|
||||
opts.num_concurrent_reqs = 1;
|
||||
opts.cache_size = 0;
|
||||
TokioResolver::builder_with_config(
|
||||
ResolverConfig::udp_and_tcp(&CLOUDFLARE),
|
||||
TokioRuntimeProvider::default(),
|
||||
)
|
||||
.with_options(opts)
|
||||
.build()
|
||||
.unwrap()
|
||||
};
|
||||
let (dnssec, plain) = (build(true), build(false));
|
||||
|
||||
let validated =
|
||||
validated_lookup(&dnssec, &plain, name("aspmx.l.google.com."), RecordType::A)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(validated.insecure);
|
||||
assert!(!validated.lookup.answers().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,10 @@ use mail_auth::{
|
||||
common::verify::VerifySignature,
|
||||
dkim2::Dkim2Output,
|
||||
dmarc::{self},
|
||||
report::{AuthFailureType, IdentityAlignment, PolicyPublished, Record, SPFDomainScope},
|
||||
report::{
|
||||
ActionDisposition, AuthFailureType, IdentityAlignment, PolicyPublished, Record, Report,
|
||||
SPFDomainScope,
|
||||
},
|
||||
};
|
||||
use registry::{
|
||||
schema::{
|
||||
@@ -459,7 +462,7 @@ impl DmarcReporting for Server {
|
||||
.await
|
||||
.unwrap_or_else(|| "MAILER-DAEMON@localhost".to_compact_string());
|
||||
let mut message = Vec::with_capacity(2048);
|
||||
let _ = mail_auth::report::Report::from(report.report).write_rfc5322(
|
||||
let _ = with_compatible_dispositions(Report::from(report.report)).write_rfc5322(
|
||||
&self
|
||||
.eval_if(
|
||||
&self.core.smtp.report.submitter,
|
||||
@@ -710,3 +713,55 @@ impl DmarcReporting for Server {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// inbuxa: RFC 9990 added "pass" to the evaluated disposition for mail that
|
||||
// passed DMARC under an enforcing policy. Cloudflare's report intake rejects
|
||||
// the whole report with "555 5.7.1 invalid_report_schema" when it sees that
|
||||
// value, and older parsers built on the RFC 7489 schema do the same. "none"
|
||||
// (no action taken) is valid under both and says the same thing, so reports
|
||||
// go out with that instead.
|
||||
fn with_compatible_dispositions(mut report: Report) -> Report {
|
||||
for record in &mut report.record {
|
||||
let disposition = &mut record.row.policy_evaluated.disposition;
|
||||
if *disposition == ActionDisposition::Pass {
|
||||
*disposition = ActionDisposition::None;
|
||||
}
|
||||
}
|
||||
report
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use mail_auth::report::DmarcResult;
|
||||
|
||||
fn record(disposition: ActionDisposition) -> Record {
|
||||
Record::new()
|
||||
.with_source_ip("192.0.2.1".parse().unwrap())
|
||||
.with_count(1)
|
||||
.with_action_disposition(disposition)
|
||||
.with_dmarc_dkim_result(DmarcResult::Pass)
|
||||
.with_dmarc_spf_result(DmarcResult::Fail)
|
||||
.with_header_from("example.org")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pass_disposition_is_reported_as_none() {
|
||||
let xml = with_compatible_dispositions(
|
||||
Report::new()
|
||||
.with_domain("example.org")
|
||||
.with_record(record(ActionDisposition::Pass))
|
||||
.with_record(record(ActionDisposition::Quarantine))
|
||||
.with_record(record(ActionDisposition::Reject)),
|
||||
)
|
||||
.to_xml();
|
||||
|
||||
assert!(!xml.contains("<disposition>pass</disposition>"), "{xml}");
|
||||
assert!(xml.contains("<disposition>none</disposition>"), "{xml}");
|
||||
assert!(
|
||||
xml.contains("<disposition>quarantine</disposition>"),
|
||||
"{xml}"
|
||||
);
|
||||
assert!(xml.contains("<disposition>reject</disposition>"), "{xml}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ fn legacy_setting(name: &str, is_set: impl Fn(&str) -> bool) -> Option<String> {
|
||||
#[macro_export]
|
||||
macro_rules! brand_version {
|
||||
() => {
|
||||
"2026.9.28.1"
|
||||
"2026.9.28.3"
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
@@ -34,6 +34,12 @@ account which way its switches point (test 13), and the impact panel's
|
||||
list names who signed in over what: every account at server scope, only the
|
||||
tenant's own at tenant scope, rewritten at most once an hour (LP-15).
|
||||
|
||||
Then one switch per protocol: POP3 alone off closes only POP3's port and
|
||||
refuses only POP3 sign-in, sending and IMAP go on, and only POP3 stops being
|
||||
advertised; one /set can close one protocol and reopen another. And a
|
||||
tenant turning POP3 off for itself, and not able to turn IMAP back on while
|
||||
the server has IMAP off.
|
||||
|
||||
Passwords are generated into files under target/e2e and never printed.
|
||||
Everything is removed afterwards unless KEEP=1.
|
||||
"""
|
||||
@@ -380,6 +386,37 @@ def tenant_checks(admin, admin_pw, account):
|
||||
"and its user signs in over IMAP again")
|
||||
check(session_flag(tu, user_pw) == "enabled", "and its session says enabled again (test 13)")
|
||||
|
||||
# One protocol at a time, for a tenant.
|
||||
tone = lambda update: one(ta, tadmin_pw, "inbuxa:TenantProtocolPolicy/set",
|
||||
{"accountId": tacct, "update": {t: update}})
|
||||
res = tone({"pop3": "disabled"})
|
||||
check(t in (res[1].get("updated") or {}), "a tenant admin turns POP3 alone off")
|
||||
check(pop3_login(PORTS["pop3"], tu, user_pw) ==
|
||||
"-ERR [AUTH] Your organization allows only inbuxa webmail and JMAP apps. "
|
||||
"This mail app can't sign in.", "the tenant's user is refused over POP3")
|
||||
check(imap_login(PORTS["imap"], tu, user_pw).startswith("OK"),
|
||||
"and still signs in over IMAP")
|
||||
check(smtp_auths(PORTS["submissions"], tu, [user_pw])[0].startswith("235"),
|
||||
"and still sends")
|
||||
check(pop3_login(PORTS["pop3"], admin, admin_pw).startswith("+OK"),
|
||||
"an account outside the tenant still signs in over POP3")
|
||||
check(session_allowed(tu, user_pw) == ["imap", "manageSieve", "submission"],
|
||||
"the tenant user's session leaves POP3 out")
|
||||
one(admin, admin_pw, "inbuxa:ProtocolPolicy/set",
|
||||
{"accountId": account, "update": {"singleton": {"imap": "disabled"}}})
|
||||
res = tone({"imap": "enabled"})
|
||||
refused = (res[1].get("notUpdated") or {}).get(t) or {}
|
||||
check(refused.get("type") == "forbidden"
|
||||
and (refused.get("description") or "").startswith("IMAP is off"),
|
||||
"with IMAP off server-wide, the tenant can't turn IMAP on, and is told which (LP-9)")
|
||||
res = tone({"pop3": "enabled"})
|
||||
check(t in (res[1].get("updated") or {}), "but it can turn its own POP3 back on")
|
||||
check(session_allowed(tu, user_pw) == ["pop3", "manageSieve", "submission"],
|
||||
"the session follows: IMAP off by the server, POP3 back")
|
||||
one(admin, admin_pw, "inbuxa:ProtocolPolicy/set",
|
||||
{"accountId": account, "update": {"singleton": {"imap": "enabled"}}})
|
||||
check(settle(PORTS["imap"], True), "IMAP back after the server's switch returns")
|
||||
|
||||
# A deleted tenant's switch goes with it, so a tenant that later gets the
|
||||
# same id doesn't start with legacy protocols off.
|
||||
sget = lambda ids: one(admin, admin_pw, "inbuxa:TenantProtocolPolicy/get",
|
||||
@@ -406,6 +443,67 @@ def session_flag(user, password):
|
||||
return sess["accounts"][acct]["accountCapabilities"].get(INBUXA, {}).get("legacyProtocols")
|
||||
|
||||
|
||||
def session_allowed(user, password):
|
||||
"""legacyAllowed from the account's urn:inbuxa:jmap capability."""
|
||||
sess = session(user, password)
|
||||
acct = sess["primaryAccounts"].get(INBUXA) or list(sess["accounts"])[0]
|
||||
return sess["accounts"][acct]["accountCapabilities"].get(INBUXA, {}).get("legacyAllowed")
|
||||
|
||||
|
||||
def per_protocol_checks(admin, admin_pw, account):
|
||||
"""One switch per protocol, server-wide."""
|
||||
pset = lambda update: one(admin, admin_pw, "inbuxa:ProtocolPolicy/set",
|
||||
{"accountId": account, "update": {"singleton": update}})
|
||||
pget = lambda: one(admin, admin_pw, "inbuxa:ProtocolPolicy/get",
|
||||
{"accountId": account, "ids": None})[1]["list"][0]
|
||||
changes = len(events("security.legacy-protocols-changed"))
|
||||
|
||||
res = pset({"pop3": "disabled"})
|
||||
check("singleton" in (res[1].get("updated") or {}), "POP3 alone can be turned off")
|
||||
check(settle(PORTS["pop3"], False), "POP3 stopped accepting")
|
||||
check(accepts(PORTS["imap"]), "IMAP still accepts with only POP3 off")
|
||||
policy = pget()
|
||||
check((policy["imap"], policy["pop3"], policy["manageSieve"], policy["legacyProtocols"])
|
||||
== ("enabled", "disabled", "enabled", "enabled"),
|
||||
"the switches read back: POP3 off, the rest on, the kill-all not set")
|
||||
check(imap_login(PORTS["imap"], admin, admin_pw).startswith("OK"),
|
||||
"IMAP sign-in works with only POP3 off")
|
||||
check(smtp_auths(PORTS["submissions"], admin, [admin_pw])[0].startswith("235"),
|
||||
"submission sign-in works: sending goes on while any protocol is allowed")
|
||||
check(session_allowed(admin, admin_pw) == ["imap", "manageSieve", "submission"],
|
||||
"the session lists what is still allowed")
|
||||
check(session_flag(admin, admin_pw) == "enabled",
|
||||
"and the old legacyProtocols flag still says enabled")
|
||||
during = advertised(admin, admin_pw)
|
||||
check(during["autoconfig"] == {"imap", "smtp"}, "autoconfig drops POP3 only")
|
||||
check("pop3" not in during["pacc"] and {"imap", "smtp"} <= during["pacc"],
|
||||
"PACC drops POP3 only")
|
||||
check(during["srv"].get("_pop3s._tcp") == "." and during["srv"].get("_imaps._tcp") != ".",
|
||||
"the zone marks POP3 not offered and still offers IMAP")
|
||||
changed = events("security.legacy-protocols-changed")[changes:]
|
||||
check(len(changed) == 1 and 'value = "pop3 disabled"' in changed[0]
|
||||
and 'details = "closed"' in changed[0],
|
||||
"turning POP3 off is one event naming it")
|
||||
if len(changed) != 1:
|
||||
print(" events:", changed)
|
||||
|
||||
# One /set can close one protocol and bring another back.
|
||||
pset({"pop3": "enabled", "imap": "disabled"})
|
||||
check(settle(PORTS["imap"], False), "IMAP closes in the same change")
|
||||
check(settle(PORTS["pop3"], True), "that brings POP3 back")
|
||||
check(pop3_login(PORTS["pop3"], admin, admin_pw).startswith("+OK"),
|
||||
"POP3 sign-in works again")
|
||||
changed = events("security.legacy-protocols-changed")[changes + 1:]
|
||||
check(len(changed) == 1 and 'details = "closed and reopened"' in changed[0],
|
||||
"and it is one event, closed and reopened")
|
||||
|
||||
pset({"imap": "enabled"})
|
||||
check(settle(PORTS["imap"], True), "IMAP back on")
|
||||
policy = pget()
|
||||
check(not policy["savedListeners"] and policy["legacyProtocols"] == "enabled",
|
||||
"nothing left saved once every protocol is on")
|
||||
|
||||
|
||||
def events_matching(name, *parts):
|
||||
return any(all(p in line for p in parts) for line in events(name))
|
||||
|
||||
@@ -636,6 +734,9 @@ def main():
|
||||
check(after["autoconfig"] == before["autoconfig"] and after["srv"] == before["srv"],
|
||||
"autoconfig and the suggested zone offer them again once back on")
|
||||
|
||||
# One switch per protocol.
|
||||
per_protocol_checks(admin, admin_pw, account)
|
||||
|
||||
# A tenant's own switch (LP-9 to LP-14a).
|
||||
tenant_checks(admin, admin_pw, account)
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* 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::{
|
||||
@@ -174,7 +176,8 @@ async fn report_dmarc() {
|
||||
let source_ip = record.source_ip().unwrap();
|
||||
if source_ip == "192.168.1.2".parse::<IpAddr>().unwrap() {
|
||||
assert_eq!(record.count(), 2);
|
||||
assert_eq!(record.action_disposition(), ActionDisposition::Pass);
|
||||
// inbuxa: "pass" goes out as "none" for RFC 7489 parsers
|
||||
assert_eq!(record.action_disposition(), ActionDisposition::None);
|
||||
assert_eq!(record.envelope_from(), "[email protected]");
|
||||
assert_eq!(record.header_from(), "[email protected]");
|
||||
assert_eq!(record.envelope_to().unwrap(), "[email protected]");
|
||||
|
||||
Reference in New Issue
Block a user