Files
inbuxa-server/crates/common/src/manager/boot.rs
T
jcoffey-dev 999ae12cc7
ci / fork-checks (pull_request) Successful in 44s
ci / build (pull_request) Successful in 3m22s
Settings reload: no DNS at build time, don't refuse over old failures
A 3-node rehearsal found every settings reload refused, cluster-wide,
because one node couldn't resolve the Pyzor server:

- PyzorConfig::parse resolved the host while building the settings and
  made a failed lookup a build error. It now keeps the host and port and
  resolves when a message is checked (an IP address is used as is, a
  name is reused for five minutes, the lookup counts against the Pyzor
  timeout). A failure there is a Pyzor error for that message.
- A milter's hostname was resolved the same way, with a blocking
  to_socket_addrs in async code. An IP address is kept; a name is now
  resolved on each connection.

Other build-time I/O is already non-fatal: directories that can't
connect become unavailable with a warning (DIR-21), and the AI model
locality check only warns.

reload_registry swapped the core only when the whole build was free of
errors, while boot runs with whatever built. One failing object thus
refused every later reload, and the running settings went stale. Now a
reload is refused only for errors in objects that built when the
running settings were built (at boot or by the last applied reload):
applying it would lose those. Objects that already failed then are
missing from the running settings anyway, as at boot, so their errors
are logged and returned as known_errors but don't hold the reload back.
Refusing on new errors keeps a bad edit from taking a working object
out of service; the admin gets the error instead.

ReloadSettings now says "Settings were not reloaded." and names the
object and its error ("Tracer with id ...: Only one console tracer is
allowed"), with a count of any further errors. A refused reload after a
directory change logs its errors too.

system::reload::reload_tests (new): with Pyzor enabled on an
unresolvable host, ReloadSettings succeeds (on main it fails with
"Invalid address: failed to lookup address information"); an IP host
needs no lookup; a new build error refuses the reload, names the object
and leaves the running settings unchanged; the same error, once known
from the running settings' build, no longer blocks; once fixed, a new
error there blocks again. smtp::inbound::milter's session test now
names its milter "localhost", so the connect-time lookup is exercised.
2026-09-24 12:11:41 -07:00

314 lines
11 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::{backup::BackupParams, console::store_console};
use crate::{
BuildServer, Caches, Core, Data, IPC_CHANNEL_BUFFER, Inner, Ipc,
config::{
network::AsnGeoLookupConfig, server::Listeners, storage::Storage, telemetry::Telemetry,
},
ipc::{BroadcastEvent, PushEvent, QueueEvent, ReportingEvent, TrainTaskController},
manager::defaults::BootstrapDefaults,
};
use arc_swap::ArcSwap;
use std::{
net::{IpAddr, Ipv4Addr},
path::PathBuf,
sync::Arc,
};
use store::{RegistryStore, registry::bootstrap::Bootstrap};
use tokio::sync::{Notify, mpsc};
use utils::{UnwrapFailure, failed};
pub struct BootManager {
pub bootstrap: Bootstrap,
pub inner: Arc<Inner>,
pub servers: Listeners,
pub ipc_rxs: IpcReceivers,
}
pub struct IpcReceivers {
pub push_rx: Option<mpsc::Receiver<PushEvent>>,
pub queue_rx: Option<mpsc::Receiver<QueueEvent>>,
pub report_rx: Option<mpsc::Receiver<ReportingEvent>>,
pub broadcast_rx: Option<mpsc::Receiver<BroadcastEvent>>,
}
const HELP: &str = concat!(
types::brand_server!(),
" ",
types::brand_version_full!(),
r#"
Usage: inbuxa [OPTIONS]
Options:
-c, --config <PATH> Start server with the specified configuration file
-e, --export <PATH> Export all store data to a specific path
-i, --import <PATH> Import store data from a specific path
-o, --console Open the store console
-h, --help Print help
-V, --version Print version
An export holds everything in the data and blob stores except short-lived
in-memory state (rate limits, locks, greylisting) and the full-text search
index, which belongs to one search backend. An import into an empty store
queues the index to be rebuilt when the server next starts. EXPORT_TYPES
limits an export to some of: data, registry, blob, changelog, queue, report,
telemetry, tasks.
"#
);
#[derive(PartialEq, Eq)]
enum StoreOp {
Export(BackupParams),
Import(PathBuf),
Console,
None,
}
impl BootManager {
pub async fn init() -> Self {
let mut config_path = std::env::var("CONFIG_PATH").ok();
let mut import_export = StoreOp::None;
if config_path.is_none() {
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next().and_then(|arg| {
arg.strip_prefix("--")
.or_else(|| arg.strip_prefix('-'))
.map(|arg| arg.to_string())
}) {
let (key, value) = if let Some((key, value)) = arg.split_once('=') {
(key.to_string(), Some(value.trim().to_string()))
} else {
(arg, args.next())
};
match (key.as_str(), value) {
("help" | "h", _) => {
eprintln!("{HELP}");
std::process::exit(0);
}
("version" | "V", _) => {
println!("{}", types::brand_version_full!());
std::process::exit(0);
}
("config" | "c", Some(value)) => {
config_path = Some(value);
}
("export" | "e", Some(value)) => {
import_export = StoreOp::Export(BackupParams::new(value.into()));
}
("import" | "i", Some(value)) => {
import_export = StoreOp::Import(value.into());
}
("console" | "o", None) => {
import_export = StoreOp::Console;
}
(_, None) => {
failed(&format!("Unrecognized command '{key}', try '--help'."));
}
(_, Some(_)) => failed(&format!(
"Missing value for argument '{key}', try '--help'."
)),
}
}
if config_path.is_none() {
if import_export == StoreOp::None {
eprintln!("{HELP}");
} else {
eprintln!("Missing '--config' argument for import/export.")
}
std::process::exit(0);
}
}
// Initialize registry
let registry = RegistryStore::init(
PathBuf::from(config_path.unwrap()),
import_export == StoreOp::None,
)
.await
.failed("⚠️ Startup failed");
let mut bootstrap = Bootstrap::new(registry).await;
// Add safe defaults if missing
if import_export == StoreOp::None {
bootstrap.insert_safe_defaults().await;
}
// Start listeners
let mut servers = Listeners::parse(&mut bootstrap).await;
servers.bind_and_drop_priv(&mut bootstrap);
// Parse storage
let storage = Storage::parse(&mut bootstrap).await;
// Parse telemetry
let telemetry = Telemetry::parse(&mut bootstrap, &storage).await;
match import_export {
StoreOp::None => {
// Parse components
let core: Box<Core> =
Box::new(Box::pin(Core::parse(&mut bootstrap, storage)).await);
let data = Data::parse(&mut bootstrap).await;
let cache = Caches::parse(&mut bootstrap).await;
// Enable telemetry
telemetry.enable();
if bootstrap.registry.is_bootstrap_mode() {
trc::event!(
Server(trc::ServerEvent::BootstrapMode),
Hostname = bootstrap.registry.local_hostname().to_string(),
Details =
"No configuration file was found. Port 8080 is open for initial setup.",
Version = types::brand_version_full!(),
);
} else if bootstrap.registry.is_recovery_mode() {
trc::event!(
Server(trc::ServerEvent::RecoveryMode),
Details = "Port 8080 is open for troubleshooting and recovery.",
Hostname = bootstrap.registry.local_hostname().to_string(),
Version = types::brand_version_full!(),
);
} else {
trc::event!(
Server(trc::ServerEvent::Startup),
Hostname = bootstrap.registry.local_hostname().to_string(),
Version = types::brand_version_full!(),
);
}
if core.storage.coordinator.is_enabled() {
trc::event!(
Cluster(trc::ClusterEvent::Startup),
Id = bootstrap.registry.node_id(),
Type = bootstrap
.registry
.cluster_role()
.unwrap_or("[default]")
.to_string(),
Details = bootstrap.registry.cluster_push_shard()
);
}
// Build shared inner
let has_remote_asn = matches!(
core.network.asn_geo_lookup,
AsnGeoLookupConfig::Resource { .. }
);
let (ipc, ipc_rxs) = build_ipc(!core.storage.coordinator.is_none());
let inner = Arc::new(Inner {
shared_core: ArcSwap::new(Arc::from(core)),
data,
ipc,
cache,
});
if !bootstrap.registry.is_recovery_mode() {
// Load spam model
if let Err(err) = inner.build_server().spam_model_reload().await {
trc::error!(
err.details("Failed to load spam filter model")
.caused_by(trc::location!())
);
}
// Fetch ASN database
if has_remote_asn {
inner
.build_server()
.lookup_asn_country(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)))
.await;
}
}
// Parse TCP acceptors
servers
.parse_tcp_acceptors(&mut bootstrap, inner.clone())
.await;
// inbuxa: a reload isn't refused over objects that failed here
inner.build_server().record_build_errors(&bootstrap.errors);
BootManager {
inner,
bootstrap,
servers,
ipc_rxs,
}
}
StoreOp::Export(path) => {
// Enable telemetry
telemetry.enable();
// Parse settings and backup
Box::pin(Core::parse(&mut bootstrap, storage))
.await
.backup(path)
.await;
std::process::exit(0);
}
StoreOp::Import(path) => {
// Enable telemetry
telemetry.enable();
// Parse settings and restore
let core = Box::pin(Core::parse(&mut bootstrap, storage)).await;
let imported = core.restore(path).await;
// inbuxa: the search index isn't exported; rebuild it
core.queue_reindex(&imported).await;
std::process::exit(0);
}
StoreOp::Console => {
// Store console
store_console(
Box::pin(Core::parse(&mut bootstrap, storage))
.await
.storage
.data,
)
.await;
std::process::exit(0);
}
}
}
}
pub fn build_ipc(has_pubsub: bool) -> (Ipc, IpcReceivers) {
// Build ipc receivers
let (push_tx, push_rx) = mpsc::channel(IPC_CHANNEL_BUFFER);
let (queue_tx, queue_rx) = mpsc::channel(IPC_CHANNEL_BUFFER);
let (report_tx, report_rx) = mpsc::channel(IPC_CHANNEL_BUFFER);
let (broadcast_tx, broadcast_rx) = mpsc::channel(IPC_CHANNEL_BUFFER);
(
Ipc {
push_tx,
queue_tx,
report_tx,
broadcast_tx: has_pubsub.then_some(broadcast_tx),
task_tx: Arc::new(Notify::new()),
task_locks: Arc::new(crate::ipc::TaskLocks::default()),
train_task_controller: Arc::new(TrainTaskController::default()),
},
IpcReceivers {
push_rx: Some(push_rx),
queue_rx: Some(queue_rx),
report_rx: Some(report_rx),
broadcast_rx: has_pubsub.then_some(broadcast_rx),
},
)
}