Files
chatmail-core/src/automatic_relay_management.rs
Hocuri db13d08f6c feat: Basic multi-relay onboarding (#8444)
If multi-relay onboarding config is set from UIs, automatically add
relays until there are 3 relays. For now, there will be a hardcoded list
of relay candidates.

- We need a list of chatmail relays that we somehow trust, and that
agree to be in the list. Then, we add all of them to the candidate list
(a new SQL table with colums "host" and "last_tried").
- Before going to IMAP IDLE: When there are less than 3 relays, fill it
up with relays from the candidate list. If creating an account fails,
try again with another relay from the list. For each candidate, we need
to remember the last time we tried to add a transport there, and try at
most once a week or so per candidate.
- For now, this will be behind an off-by-default config option, which at
least DC Android will enable when creating a new profile. UIs can then
opt in on their own pace but will likely need to disable it for tests.
- Right now, the backoff times are: Try to add a relay at most once per
hour, and try to add the same relay at most once per week
2026-07-31 13:14:43 +02:00

179 lines
6.2 KiB
Rust

use std::pin::Pin;
use anyhow::Result;
use deltachat_contact_tools::addr_normalize;
use rand::distr::{Alphanumeric, SampleString};
use rand::seq::IndexedRandom;
use crate::config::{self, Config};
use crate::log::{LogExt, warn};
use crate::login_param::{EnteredCertificateChecks, EnteredImapLoginParam};
use crate::{configure::EnteredLoginParam, context::Context, tools::time};
/// The target number of transports.
const NUM_TRANSPORTS_TARGET: usize = 3;
/// How often we want to try adding new relays.
const AUTOMATIC_ADDITION_DEBOUNCE_SECONDS: i64 = 60 * 60; // one hour
/// How long we ignore a relay candidate after failing to connect to it:
const BACKOFF_PERIOD_FOR_NOT_WORKING_RELAY: i64 = 60 * 60 * 24 * 7; // one week
pub(crate) fn maybe_add_additional_relays(
context: Context,
) -> Pin<Box<dyn Future<Output = ()> + Send>> {
// We need to Box::pin the future because it wouldn't compile otherwise
// because Rust async doesn't support recursion:
// `maybe_add_additional_relays_inner()` calls `restart_io_if_running()`,
// which (via several other functions) calls `imap_loop()`,
// which (via several other functions) calls `maybe_add_additional_relays()`
Box::pin(async move {
let skip_network = false;
let relay_added = maybe_add_additional_relays_inner(&context, skip_network)
.await
.log_err(&context)
.unwrap_or(false);
if relay_added {
info!(context, "Restarting IO after relay addition");
context.restart_io_if_running().await;
}
})
}
async fn maybe_add_additional_relays_inner(context: &Context, skip_network: bool) -> Result<bool> {
let now = time();
let Ok(_lock) = context.background_task_mutex.try_lock() else {
// Housekeeping or automatic relay management is already running in another thread, do nothing.
return Ok(false);
};
let last_timestamp = context
.get_config_i64(Config::LastAutomaticRelayManagement)
.await?;
if last_timestamp > now {
warn!(
context,
"Clock ran backwards, unclear if automatic relay management should run. Will run it anyways."
);
} else if last_timestamp > now.saturating_sub(AUTOMATIC_ADDITION_DEBOUNCE_SECONDS) {
return Ok(false);
}
if !context
.get_config_bool(Config::AutomaticRelayManagement)
.await?
{
return Ok(false);
}
if context
.get_config_bool(Config::AutomaticRelayManagementFinished)
.await?
{
return Ok(false);
}
// Set the config at the beginning to avoid endless loops.
// Race conditions are not a concern because we locked the mutex.
context
.set_config_internal(Config::LastAutomaticRelayManagement, Some(&now.to_string()))
.await?;
let mut relay_added = false;
// Using `for` instead of `while` to prevent infinite loop
for _ in 0..NUM_TRANSPORTS_TARGET {
if context.count_transports().await? >= NUM_TRANSPORTS_TARGET {
context
.set_config_internal(
Config::AutomaticRelayManagementFinished,
config::from_bool(true),
)
.await?;
return Ok(relay_added);
}
// First, query all candidates that were not tried since `BACKOFF_PERIOD_FOR_NOT_WORKING_RELAY` seconds.
// Hosts that are already used are excluded.
let candidates = load_relay_candidates(context, now).await?;
let Some(host) = candidates.choose(&mut rand::rng()) else {
info!(
context,
"maybe_add_additional_relays: No suitable candidates"
);
return Ok(relay_added);
};
info!(
context,
"Trying to automatically add relay {host} (there were {} candidates).",
candidates.len(),
);
context
.sql
.execute(
"UPDATE relay_candidates SET last_tried=? WHERE host=?",
(now, host),
)
.await?;
let param = login_param_from_host(host);
let res = crate::configure::configure(context, &param, skip_network).await;
if let Err(e) = res {
warn!(
context,
"Failed to automatically add a relay {host}: {e:#}."
);
} else {
info!(context, "Successfully automatically added relay {host}.");
relay_added = true;
}
}
Ok(relay_added)
}
async fn load_relay_candidates(context: &Context, now: i64) -> Result<Vec<String>, anyhow::Error> {
let cutoff_timestamp = now.saturating_sub(BACKOFF_PERIOD_FOR_NOT_WORKING_RELAY);
let candidates: Vec<String> = context
.sql
.query_map_vec(
// This also selects candidates which have last_tried in the future,
// essentially treating them as never tried,
// so if some timestamp far in the future is accidentally stored,
// we are not stuck never trying the candidate.
// After trying the candidate, last_tried will be corrected to the current time.
"SELECT host FROM relay_candidates WHERE (last_tried<? OR last_tried>?)
AND NOT EXISTS (
SELECT 1
FROM transports
WHERE substr(addr, instr(addr, '@') + 1) = host
)",
(cutoff_timestamp, now),
|row| Ok(row.get::<_, String>(0)?),
)
.await?;
Ok(candidates)
}
pub(crate) fn login_param_from_host(host: &str) -> EnteredLoginParam {
let rng = &mut rand::rng();
let username = Alphanumeric.sample_string(rng, 9);
let addr = username + "@" + host;
let addr = addr_normalize(&addr);
// 22 * log2(26 * 2 + 10) = 130 bits of entropy
let password = Alphanumeric.sample_string(rng, 22);
EnteredLoginParam {
addr,
imap: EnteredImapLoginParam {
password,
..Default::default()
},
smtp: Default::default(),
certificate_checks: EnteredCertificateChecks::Strict,
oauth2: false,
}
}
#[cfg(test)]
mod automatic_relay_management_tests;