Files
Stalwart/crates/common/src/config/network.rs

548 lines
19 KiB
Rust

/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use super::*;
use crate::{
expr::if_block::{BootstrapExprExt, IfBlock},
network::{
autoconfig::pacc::{
Authentication, Configuration, HttpServer, Info, Logo, OAuthPublic, Protocols,
Provider, TextServer,
},
security::Security,
},
};
use registry::schema::{
enums::{AcmeChallengeType, ClusterTaskType, ProviderInfo, ServiceProtocol},
prelude::ObjectType,
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 {
pub pacc: 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)>,
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: "Stalwart".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 (prefix, suffix) = serde_json::to_string(&pacc)
.unwrap_or_default()
.rsplit_once(SPLIT_HERE)
.map(|(prefix, suffix)| (prefix.to_string(), suffix.to_string()))
.unwrap();
let 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: Pacc { prefix, suffix },
},
};
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
}
}
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();
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: 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,
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,
}
}
}