Files
inbuxa-server/crates/common/src/config/network.rs
T
jcoffey-dev 8e9cedbe97
ci / fork-checks (pull_request) Successful in 43s
ci / build (pull_request) Successful in 7m40s
Give IMAP, POP3 and ManageSieve a switch each
The legacy-protocols switch was all or nothing. An operator can now stop
POP3 and keep IMAP: each of IMAP, POP3 and ManageSieve has its own
switch, server-wide on inbuxa:ProtocolPolicy and per tenant on
inbuxa:TenantProtocolPolicy (properties imap, pop3, manageSieve).

legacyProtocols stays as the kill-all: setting it sets all three, and it
reads "disabled" exactly when all three are off. A policy stored before
this has only legacyProtocols and reads as all three at that value, so
existing servers and tenants carry over unchanged. In one /set, a
protocol named beside legacyProtocols overrides it.

SMTP submission keeps no switch of its own: sign-in over it is refused
only when all three are off, as the single switch did (LP-6), so
turning one protocol off never stops a mail app sending. For a tenant,
the server's switches and the tenant's count together.

Server-wide, a change closes the listeners of whatever is now off and
puts back the saved listeners of whatever is on again, both in one
change if asked; listeners of a protocol still off stay saved. Sign-in,
autoconfig, autodiscover, PACC (now prepared once per combination) and
the suggested DNS records all follow each protocol separately. A tenant
may turn a protocol on only while the server has it on (LP-9), and the
refusal names which. The JMAP session adds legacyAllowed, the protocols
still allowed for the account; legacyProtocols there keeps its meaning
for older webmail builds. Events name the switches ("pop3 disabled"),
and audit before/after reads every switch even from an older policy.

Tested: unit tests for the switches, the old-policy reading, the
server/tenant combination, the tenant refusal and listener refusal; and
tests/e2e/legacy_protocols.py against a running server, all 100 checks,
including new ones: POP3 alone off closes only its port and refuses
only its sign-in while IMAP and sending go on; only POP3 stops being
advertised; one change closes IMAP and reopens POP3; a tenant turns
POP3 off for itself, and can't turn IMAP on while the server has it off.
2026-09-27 23:12:32 -07:00

602 lines
21 KiB
Rust

/*
* 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 super::*;
use crate::{
expr::if_block::{BootstrapExprExt, IfBlock},
network::{
autoconfig::pacc::{
Authentication, Configuration, HttpServer, Info, Logo, OAuthPublic, Protocols,
Provider, TextServer,
},
security::Security,
},
};
use mail_builder::mime::make_boundary;
use registry::schema::{
enums::{AcmeChallengeType, ClusterTaskType, ProviderInfo, ServiceProtocol},
prelude::{ObjectType, Property},
structs::{
self, AcmeProvider, Asn, ClusterTaskGroup, HttpForm, MailExchanger, Rate, Service,
SystemSettings, TaskManager,
},
};
use std::{str::FromStr, time::Duration};
use utils::map::vec_map::VecMap;
#[derive(Clone)]
pub struct Network {
pub node_id: u64,
pub roles: ClusterRoles,
pub server_name: String,
pub security: Security,
pub http: Http,
pub contact_form: Option<ContactForm>,
pub asn_geo_lookup: AsnGeoLookupConfig,
pub task_manager: TaskManager,
pub has_acme_tls_challenge: bool,
pub has_acme_http_challenge: bool,
pub info: NetworkInfo,
}
#[derive(Clone)]
pub struct NetworkInfo {
/// 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>,
}
#[derive(Clone)]
pub struct Pacc {
pub prefix: String,
pub suffix: String,
}
#[derive(Clone)]
pub struct Http {
pub rate_authenticated: Option<Rate>,
pub rate_anonymous: Option<Rate>,
pub url_https: String,
pub allowed_endpoint: IfBlock,
pub response_headers: Vec<(hyper::header::HeaderName, hyper::header::HeaderValue)>,
/// inbuxa: origins allowed cross-origin access (contract C-14). Empty when
/// CORS is permissive (bootstrap, recovery, or `usePermissiveCors`).
pub cors_origins: Vec<hyper::header::HeaderValue>,
pub use_forwarded: bool,
pub redirect_root: Option<String>,
}
#[derive(Clone)]
pub struct ContactForm {
pub rcpt_to: Vec<String>,
pub max_size: usize,
pub rate: Option<Rate>,
pub validate_domain: bool,
pub from_email: FieldOrDefault,
pub from_subject: FieldOrDefault,
pub from_name: FieldOrDefault,
pub field_honey_pot: Option<String>,
}
#[derive(Clone)]
pub struct ClusterRoles {
pub store_maintenance: bool,
pub account_maintenance: bool,
pub push_notifications: bool,
pub search_indexing: bool,
pub spam_training: bool,
pub metrics_calculate: bool,
pub metrics_push: bool,
pub outbound_mta: bool,
pub task_scheduler: bool,
pub task_manager: bool,
}
#[derive(Clone, Default)]
pub enum AsnGeoLookupConfig {
Resource {
expires: Duration,
timeout: Duration,
max_size: usize,
headers: HeaderMap,
asn_resources: Vec<String>,
geo_resources: Vec<String>,
},
Dns {
zone_ipv4: String,
zone_ipv6: String,
separator: String,
index_asn: usize,
index_asn_name: Option<usize>,
index_country: Option<usize>,
},
#[default]
Disabled,
}
#[derive(Clone)]
pub struct FieldOrDefault {
pub field: Option<String>,
pub default: String,
}
impl ContactForm {
pub async fn parse(bp: &mut Bootstrap) -> Option<Self> {
let form = bp.setting_infallible::<HttpForm>().await;
if !form.enable {
return None;
} else if form.deliver_to.is_empty() {
bp.build_error(
ObjectType::HttpForm.singleton(),
"Contact form is enabled but no recipient addresses are configured",
);
return None;
}
Some(ContactForm {
rcpt_to: form.deliver_to.into_inner(),
max_size: form.max_size as usize,
validate_domain: form.validate_domain,
from_email: FieldOrDefault {
field: form.field_email,
default: form.default_from_address,
},
from_subject: FieldOrDefault {
field: form.field_subject,
default: form.default_subject,
},
from_name: FieldOrDefault {
field: form.field_name,
default: form.default_name,
},
field_honey_pot: form.field_honey_pot,
rate: form.rate_limit,
})
}
}
impl Network {
pub async fn parse(bp: &mut Bootstrap) -> Self {
let system = bp.setting_infallible::<SystemSettings>().await;
let mut has_acme_tls_challenge = false;
let mut has_acme_http_challenge = false;
let mut has_acme_challenges = false;
for provider in bp.list_infallible::<AcmeProvider>().await {
match provider.object.challenge_type {
AcmeChallengeType::Http01 => has_acme_http_challenge = true,
AcmeChallengeType::TlsAlpn01 => has_acme_tls_challenge = true,
_ => {}
}
has_acme_challenges = true;
}
if !has_acme_challenges {
// Assume this is an initial deployment and optimistically set both to true
// to avoid requiring a reload after ACME providers are added
has_acme_http_challenge = true;
has_acme_tls_challenge = true;
}
const SPLIT_HERE: &str = "$$__SPLIT_HERE__$$";
let mut pacc = Configuration {
protocols: Protocols::default(),
authentication: Some(Authentication {
oauth_public: Some(OAuthPublic {
issuer: SPLIT_HERE.to_string(),
}),
password: true,
}),
info: Info {
provider: Provider {
name: types::brand!().into(),
..Default::default()
},
..Default::default()
},
};
let default_hostname = if !system.default_hostname.is_empty() {
system.default_hostname.as_str()
} else {
bp.registry.local_hostname()
};
let mut http_host = default_hostname.to_string();
for (service, details) in &system.services {
let hostname = details.hostname.as_deref().unwrap_or(default_hostname);
match service {
ServiceProtocol::Jmap => {
if hostname != http_host {
http_host = hostname.to_string();
}
pacc.protocols.jmap = HttpServer {
url: format!("https://{hostname}/jmap/session",),
}
.into();
}
ServiceProtocol::Caldav => {
pacc.protocols.caldav = HttpServer {
url: format!("https://{hostname}/dav/cal/",),
}
.into();
}
ServiceProtocol::Carddav => {
pacc.protocols.carddav = HttpServer {
url: format!("https://{hostname}/dav/card/",),
}
.into();
}
ServiceProtocol::Webdav => {
pacc.protocols.webdav = HttpServer {
url: format!("https://{hostname}/dav/file/",),
}
.into();
}
ServiceProtocol::Imap => {
pacc.protocols.imap = TextServer {
host: hostname.to_string(),
}
.into();
}
ServiceProtocol::Pop3 => {
pacc.protocols.pop3 = TextServer {
host: hostname.to_string(),
}
.into();
}
ServiceProtocol::Smtp => {
pacc.protocols.smtp = TextServer {
host: hostname.to_string(),
}
.into();
}
ServiceProtocol::Managesieve => {
pacc.protocols.managesieve = TextServer {
host: hostname.to_string(),
}
.into();
}
}
}
for (tag, text) in system.provider_info {
match tag {
ProviderInfo::ProviderName => pacc.info.provider.name = text,
ProviderInfo::ProviderShortName => pacc.info.provider.short_name = Some(text),
ProviderInfo::UserDocumentation => {
pacc.info.help.get_or_insert_default().documentation = Some(text)
}
ProviderInfo::DeveloperDocumentation => {
pacc.info.help.get_or_insert_default().developer = Some(text)
}
ProviderInfo::ContactUri => {
pacc.info
.help
.get_or_insert_default()
.contact
.get_or_insert_default()
.push(text);
}
ProviderInfo::LogoUrl => {
let logo = pacc.info.provider.logo.get_or_insert_default();
if logo.is_empty() {
logo.push(Logo {
url: text,
..Default::default()
});
} else {
logo[0].url = text;
}
}
ProviderInfo::LogoWidth => {
let logo = pacc.info.provider.logo.get_or_insert_default();
if logo.is_empty() {
logo.push(Logo {
width: text.parse().ok(),
..Default::default()
});
} else {
logo[0].width = text.parse().ok();
}
}
ProviderInfo::LogoHeight => {
let logo = pacc.info.provider.logo.get_or_insert_default();
if logo.is_empty() {
logo.push(Logo {
height: text.parse().ok(),
..Default::default()
});
} else {
logo[0].height = text.parse().ok();
}
}
}
}
let split = |pacc: &Configuration| {
serde_json::to_string(pacc)
.unwrap_or_default()
.rsplit_once(SPLIT_HERE)
.map(|(prefix, suffix)| Pacc {
prefix: prefix.to_string(),
suffix: suffix.to_string(),
})
.unwrap()
};
// inbuxa: legacy-protocols LP-7, 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;
}
if off & 4 != 0 {
pacc.protocols.managesieve = None;
}
if off & 8 != 0 {
pacc.protocols.smtp = None;
}
split(&pacc)
})
.collect();
let mut network = Network {
node_id: bp.node_id() as u64,
server_name: default_hostname.to_string(),
security: Security::parse(bp).await,
contact_form: ContactForm::parse(bp).await,
asn_geo_lookup: AsnGeoLookupConfig::parse(bp).await.unwrap_or_default(),
roles: ClusterRoles::default(),
http: Http::parse(bp, &http_host).await,
task_manager: bp.setting_infallible::<TaskManager>().await,
has_acme_tls_challenge,
has_acme_http_challenge,
info: NetworkInfo {
mxs: system.mail_exchangers.into_iter().collect(),
services: system.services,
pacc,
},
};
if let Some(role) = &bp.role {
match &role.tasks {
ClusterTaskGroup::EnableAll => {}
ClusterTaskGroup::DisableAll => {
for network_role in network.roles.all_mut() {
*network_role = false;
}
}
ClusterTaskGroup::EnableSome(group) => {
for network_role in network.roles.all_mut() {
*network_role = false;
}
for task_type in group.task_types.iter() {
network.roles.set_role(*task_type, true);
}
}
ClusterTaskGroup::DisableSome(group) => {
for task_type in group.task_types.iter() {
network.roles.set_role(*task_type, false);
}
}
}
}
network
}
pub fn message_id(&self) -> String {
format!("{}@{}", make_boundary("."), self.server_name)
}
}
impl Http {
#[cfg_attr(
any(feature = "dev_mode", feature = "test_mode"),
allow(unused_variables)
)]
pub async fn parse(bp: &mut Bootstrap, server_name: &str) -> Self {
let http = bp.setting_infallible::<structs::Http>().await;
// Parse HTTP headers
let mut http_headers = http
.response_headers
.iter()
.map(|(k, v)| {
Ok((
hyper::header::HeaderName::from_str(k.trim()).map_err(|err| {
format!("Invalid header found in property \"http.headers\": {}", err)
})?,
hyper::header::HeaderValue::from_str(v.trim()).map_err(|err| {
format!("Invalid header found in property \"http.headers\": {}", err)
})?,
))
})
.collect::<Result<Vec<_>, String>>()
.map_err(|e| {
bp.build_error(
ObjectType::Http.singleton(),
format!("Failed to parse HTTP headers: {}", e),
)
})
.unwrap_or_default();
// Add permissive CORS headers
#[cfg(feature = "dev_mode")]
let use_permissive_cors = true;
#[cfg(not(feature = "dev_mode"))]
let use_permissive_cors = http.use_permissive_cors || bp.registry.is_recovery_mode();
// inbuxa: otherwise only the front ends' origins get cross-origin
// access, echoed per request (contract C-14)
let cors_origins = if use_permissive_cors {
Vec::new()
} else {
crate::manager::first_party::front_end_origins()
.into_iter()
.filter_map(|origin| hyper::header::HeaderValue::from_str(&origin).ok())
.collect()
};
if use_permissive_cors {
http_headers.push((
hyper::header::ACCESS_CONTROL_ALLOW_ORIGIN,
hyper::header::HeaderValue::from_static("*"),
));
http_headers.push((
hyper::header::ACCESS_CONTROL_ALLOW_HEADERS,
hyper::header::HeaderValue::from_static(
"Authorization, Content-Type, Accept, X-Requested-With",
),
));
http_headers.push((
hyper::header::ACCESS_CONTROL_ALLOW_METHODS,
hyper::header::HeaderValue::from_static(
"POST, GET, PATCH, PUT, DELETE, HEAD, OPTIONS",
),
));
}
// Add HTTP Strict Transport Security
if http.enable_hsts {
http_headers.push((
hyper::header::STRICT_TRANSPORT_SECURITY,
hyper::header::HeaderValue::from_static("max-age=31536000; includeSubDomains"),
));
}
#[cfg(any(feature = "dev_mode", feature = "test_mode"))]
let server_name = "127.0.0.1";
Http {
url_https: if !bp.registry.is_recovery_mode() {
if let Some(url) = bp.registry.public_url() {
url.to_string()
} else {
format!("https://{server_name}")
}
} else {
String::new()
},
allowed_endpoint: if bp.registry.is_recovery_mode() {
IfBlock::empty(ObjectType::Http.singleton(), Property::AllowedEndpoints)
} else {
bp.compile_expr(ObjectType::Http.singleton(), &http.ctx_allowed_endpoints())
},
rate_authenticated: if bp.registry.is_recovery_mode() {
None
} else {
http.rate_limit_authenticated
},
rate_anonymous: if bp.registry.is_recovery_mode() {
None
} else {
http.rate_limit_anonymous
},
response_headers: http_headers,
cors_origins,
use_forwarded: http.use_x_forwarded,
redirect_root: http.redirect_root,
}
}
}
impl AsnGeoLookupConfig {
pub async fn parse(bp: &mut Bootstrap) -> Option<Self> {
match bp.setting_infallible::<Asn>().await {
Asn::Resource(asn) => Some(AsnGeoLookupConfig::Resource {
expires: asn.expires.into_inner(),
timeout: asn.timeout.into_inner(),
max_size: asn.max_size as usize,
headers: asn
.http_auth
.build_headers(asn.http_headers, None)
.await
.map_err(|err| {
bp.build_error(
ObjectType::Asn.singleton(),
format!("Unable to build HTTP headers: {}", err),
)
})
.ok()?,
asn_resources: asn.asn_urls.into_inner(),
geo_resources: asn.geo_urls.into_inner(),
}),
Asn::Dns(asn) => Some(AsnGeoLookupConfig::Dns {
zone_ipv4: asn.zone_ip_v4,
zone_ipv6: asn.zone_ip_v6,
separator: asn.separator,
index_asn: asn.index_asn as usize,
index_asn_name: asn.index_asn_name.map(|v| v as usize),
index_country: asn.index_country.map(|v| v as usize),
}),
Asn::Disabled => None,
}
}
}
impl ClusterRoles {
fn all_mut(&mut self) -> impl Iterator<Item = &mut bool> {
[
&mut self.store_maintenance,
&mut self.account_maintenance,
&mut self.push_notifications,
&mut self.search_indexing,
&mut self.spam_training,
&mut self.outbound_mta,
&mut self.task_manager,
&mut self.task_scheduler,
&mut self.metrics_calculate,
&mut self.metrics_push,
]
.into_iter()
}
fn set_role(&mut self, role: ClusterTaskType, enabled: bool) {
match role {
ClusterTaskType::StoreMaintenance => self.store_maintenance = enabled,
ClusterTaskType::AccountMaintenance => self.account_maintenance = enabled,
ClusterTaskType::PushNotifications => self.push_notifications = enabled,
ClusterTaskType::SearchIndexing => self.search_indexing = enabled,
ClusterTaskType::SpamClassifierTraining => self.spam_training = enabled,
ClusterTaskType::MetricsCalculate => self.metrics_calculate = enabled,
ClusterTaskType::MetricsPush => self.metrics_push = enabled,
ClusterTaskType::OutboundMta => self.outbound_mta = enabled,
ClusterTaskType::TaskQueueProcessing => self.task_manager = enabled,
ClusterTaskType::TaskScheduler => self.task_scheduler = enabled,
}
}
}
impl Default for ClusterRoles {
fn default() -> Self {
ClusterRoles {
store_maintenance: true,
account_maintenance: true,
push_notifications: true,
search_indexing: true,
spam_training: true,
metrics_calculate: true,
metrics_push: true,
outbound_mta: true,
task_manager: true,
task_scheduler: true,
}
}
}