From 446bf7fc46f7c4d3673786522d8460eebe7e343f Mon Sep 17 00:00:00 2001 From: holger krekel Date: Fri, 18 Sep 2026 11:00:33 +0200 Subject: [PATCH] feat: add init_transports() for multi-relay onboarding Closes #8693 and supsersedes #8707 from which the API and some overall shape of this commit is inspired. UIs call `init_transports(None)` or `init_transports(qr)` to initialize a first transport on the fresh profile, with more transports added in the background later. --- deltachat-jsonrpc/src/api.rs | 12 ++ .../src/deltachat_rpc_client/account.py | 12 ++ .../tests/test_multitransport.py | 6 + src/autorelay.rs | 146 +++++++++---- src/autorelay/autorelay_tests.rs | 202 ++++++++++++------ src/configure.rs | 77 ++++++- src/qr.rs | 4 +- src/qr/qr_tests.rs | 17 ++ src/sql/migrations.rs | 7 + 9 files changed, 376 insertions(+), 107 deletions(-) diff --git a/deltachat-jsonrpc/src/api.rs b/deltachat-jsonrpc/src/api.rs index 1ffce2bb4..68a4906e1 100644 --- a/deltachat-jsonrpc/src/api.rs +++ b/deltachat-jsonrpc/src/api.rs @@ -546,6 +546,18 @@ impl CommandApi { ctx.add_transport_from_qr(&qr).await } + /// Adds an initial transport on a randomly chosen chatmail relay + /// and lets the profile add further ones in the background. + /// + /// A `DCACCOUNT:` or `DCLOGIN:` `qr` code adds a single transport + /// while securejoin codes add the inviter's relays to the candidates. + /// + /// Does nothing if the profile already has a transport. + async fn init_transports(&self, account_id: u32, qr: Option) -> Result<()> { + let ctx = self.get_context(account_id).await?; + ctx.init_transports(qr.as_deref()).await + } + /// Returns the list of all email accounts that are used as a transport in the current profile. /// Use [Self::add_or_update_transport()] to add or change a transport /// and [Self::delete_transport()] to remove a transport. diff --git a/deltachat-rpc-client/src/deltachat_rpc_client/account.py b/deltachat-rpc-client/src/deltachat_rpc_client/account.py index e4c20659f..42c3f28ea 100644 --- a/deltachat-rpc-client/src/deltachat_rpc_client/account.py +++ b/deltachat-rpc-client/src/deltachat_rpc_client/account.py @@ -139,6 +139,18 @@ class Account: """Add a new transport using a QR code.""" yield self._rpc.add_transport_from_qr.future(self.id, qr) + @futuremethod + def init_transports(self, qr: Optional[str] = None): + """Add an initial transport on a randomly chosen chatmail relay. + + The profile then adds further ones in the background. + A ``DCACCOUNT:`` or ``DCLOGIN:`` ``qr`` code adds a single transport + while securejoin codes add the inviter's relays to the candidates. + + Does nothing if the profile already has a transport. + """ + yield self._rpc.init_transports.future(self.id, qr) + def delete_transport(self, addr: str): """Delete a transport.""" self._rpc.delete_transport(self.id, addr) diff --git a/deltachat-rpc-client/tests/test_multitransport.py b/deltachat-rpc-client/tests/test_multitransport.py index 8c2372c6e..3002c3298 100644 --- a/deltachat-rpc-client/tests/test_multitransport.py +++ b/deltachat-rpc-client/tests/test_multitransport.py @@ -24,6 +24,12 @@ def wait_for_imap_message(imap): time.sleep(1) +def test_init_transports(acf): + account = acf.get_unconfigured_account() + account.init_transports(acf.get_account_qr()) + assert len(account.list_transports()) == 1 + + def test_add_second_address(acf) -> None: account = acf.new_configured_account() assert len(account.list_transports()) == 1 diff --git a/src/autorelay.rs b/src/autorelay.rs index 6055f7308..d32a593b2 100644 --- a/src/autorelay.rs +++ b/src/autorelay.rs @@ -1,29 +1,22 @@ -//! # Automatic relay handling (experimental, still in development) +//! # Automatic multi-relay onboarding //! -//! Chatmail relays create an account on first login, -//! so a profile can add further transports on its own without user interaction. -//! Candidate hosts come from the `relay_candidates` table, -//! which migrations seed with a list of known chatmail relays. -//! -//! Status of implementation: -//! Additions are attempted right before going into IMAP IDLE, -//! i.e. only while connected and with nothing more important to do, -//! and only if a UI opted in via [`Config::Autorelay`]. -//! Once a profile has reached `NUM_TRANSPORTS_TARGET` transports, -//! [`Config::AutorelayFinished`] is set and nothing is ever added again, -//! so deleting a transport later does not pull in a replacement. +//! Support for automatically onboarding a profile on transport +//! candidates without the user choosing a relay. +use std::collections::BTreeMap; use std::pin::Pin; -use anyhow::Result; +use anyhow::{Result, format_err}; use deltachat_contact_tools::addr_normalize; use rand::distr::{Alphanumeric, SampleString}; -use rand::seq::IndexedRandom; +use rand::seq::{IndexedRandom, SliceRandom}; +use rusqlite::Transaction; use crate::config::{self, Config}; +use crate::configure::{EnteredLoginParam, configure}; use crate::log::{LogExt, warn}; use crate::login_param::{EnteredCertificateChecks, EnteredImapLoginParam}; -use crate::{configure::EnteredLoginParam, context::Context, tools::time}; +use crate::{context::Context, tools::time}; /// The target number of transports. const NUM_TRANSPORTS_TARGET: usize = 3; @@ -32,6 +25,62 @@ 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 +/// Sorted relay list a profile can attempt to onboard on without the user choosing one. +const DEFAULT_RELAY_CANDIDATES: &[&str] = &[ + // "chat.adminforge.de", // iroh relay 404s + // "chat.feld.me", // iroh relay 404s + "chat.me.ke", + "chat.nuvon.app", + "chat.tinydispatch.org", + "chat.vim.wtf", + "chatmail.uk", + "chtml.ca", + "deltachat.me", + "e2e.sus.fr", + "mailchat.pl", + "nchrcht.la10cy.net", + "nine.testrun.org", + "sweetfern.net", + "tarpit.fun", +]; + +/// Records the hosts of `addrs` as relay candidates. +pub(crate) async fn add_relay_candidates(context: &Context, addrs: &[String]) -> Result<()> { + context + .sql + .transaction(|tx| hosts_of(addrs).try_for_each(|host| save_relay_candidate(tx, host, 0))) + .await +} + +/// Adds a first transport on a random relay candidate. +pub(crate) async fn add_transport_from_candidates( + context: &Context, + skip_network: bool, +) -> Result<()> { + let mut candidates = triable_relay_candidates(context, time()).await?; + candidates.shuffle(&mut rand::rng()); + let mut last_err = format_err!("No relay candidates"); + + // We patiently try each candidate in turn which might take a while + // if many hosts are unreachable but eventually succeeds if one candidate works. + let mark_as_autorelay = true; + for host in &candidates { + let param = login_param_from_host(host, mark_as_autorelay); + match configure(context, ¶m, skip_network).await { + Ok(()) => { + param.save_legacy(context).await?; + info!(context, "Added a transport on relay {host}."); + return Ok(()); + } + Err(err) => { + warn!(context, "Failed to add relay {host}: {err:#}."); + last_err = err; + } + } + } + Err(last_err) +} + pub(crate) fn maybe_add_additional_relays( context: Context, ) -> Pin + Send>> { @@ -93,10 +142,7 @@ async fn maybe_add_additional_relays_inner(context: &Context, skip_network: bool 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 candidates = triable_relay_candidates(context, now).await?; let Some(host) = candidates.choose(&mut rand::rng()) else { info!( context, @@ -113,14 +159,11 @@ async fn maybe_add_additional_relays_inner(context: &Context, skip_network: bool context .sql - .execute( - "UPDATE relay_candidates SET last_tried=? WHERE host=?", - (now, host), - ) + .transaction(|tx| save_relay_candidate(tx, host, now)) .await?; let mark_as_autorelay = true; let param = login_param_from_host(host, mark_as_autorelay); - let res = crate::configure::configure(context, ¶m, skip_network).await; + let res = configure(context, ¶m, skip_network).await; if let Err(e) = res { warn!( context, @@ -135,30 +178,49 @@ async fn maybe_add_additional_relays_inner(context: &Context, skip_network: bool Ok(relay_added) } -async fn load_relay_candidates(context: &Context, now: i64) -> Result, anyhow::Error> { +async fn triable_relay_candidates(context: &Context, now: i64) -> Result> { let cutoff_timestamp = now.saturating_sub(BACKOFF_PERIOD_FOR_NOT_WORKING_RELAY); - let candidates: Vec = context + let mut last_tried: BTreeMap = 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?) - AND NOT EXISTS ( - SELECT 1 - FROM transports - WHERE substr(addr, instr(addr, '@') + 1) = host - )", - (cutoff_timestamp, now), - |row| Ok(row.get::<_, String>(0)?), - ) + .query_map_collect("SELECT host, last_tried FROM relay_candidates", (), |row| { + Ok((row.get(0)?, row.get(1)?)) + }) .await?; + for host in DEFAULT_RELAY_CANDIDATES { + last_tried.entry(host.to_string()).or_insert(0); + } + let self_addrs = context.get_self_addrs().await?; + let used_hosts: Vec<&str> = hosts_of(&self_addrs).collect(); + + // We also try candidates which have `last_tried` in the future, + // which on next failure get `last_tried` reset to the current time. + let candidates = last_tried + .into_iter() + .filter(|(host, last_tried)| { + (*last_tried < cutoff_timestamp || *last_tried > now) + && !used_hosts.contains(&host.as_str()) + }) + .map(|(host, _)| host) + .collect(); Ok(candidates) } +/// Returns the host of each address in `addrs`. +fn hosts_of(addrs: &[String]) -> impl Iterator { + addrs.iter().filter_map(|a| Some(a.rsplit_once('@')?.1)) +} + +/// Records `host` as a relay candidate, overwriting a stored `last_tried`. +fn save_relay_candidate(tx: &Transaction, host: &str, last_tried: i64) -> Result<()> { + tx.execute( + "INSERT INTO relay_candidates (host, last_tried) VALUES (?, ?) + ON CONFLICT(host) DO UPDATE SET last_tried=excluded.last_tried", + (host, last_tried), + )?; + Ok(()) +} + pub(crate) fn login_param_from_host(host: &str, mark_as_autorelay: bool) -> EnteredLoginParam { let rng = &mut rand::rng(); let username = Alphanumeric.sample_string(rng, 9); diff --git a/src/autorelay/autorelay_tests.rs b/src/autorelay/autorelay_tests.rs index 9f249f7ad..a8a13bb4b 100644 --- a/src/autorelay/autorelay_tests.rs +++ b/src/autorelay/autorelay_tests.rs @@ -1,42 +1,144 @@ use std::time::Duration; use super::*; -use crate::test_utils::TestContext; +use crate::test_utils::{TestContext, TestContextManager}; use crate::tools::SystemTime; +/// Tests that the default relays are candidates without a row in the table. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_load_relay_candidates_single() -> Result<()> { +async fn test_triable_relay_candidates_defaults() -> Result<()> { + let mut tcm = TestContextManager::new(); + let t = &tcm.unconfigured().await; + let now = time(); + + assert!(DEFAULT_RELAY_CANDIDATES.is_sorted()); + let mut candidates = triable_relay_candidates(t, now).await?; + candidates.sort(); + assert_eq!(candidates, DEFAULT_RELAY_CANDIDATES); + + let tried = DEFAULT_RELAY_CANDIDATES[0]; + save_relay_candidates(t, &[tried], now).await?; + let candidates = triable_relay_candidates(t, now).await?; + assert_eq!(candidates.len(), DEFAULT_RELAY_CANDIDATES.len() - 1); + assert!(!candidates.contains(&tried.to_string())); + + Ok(()) +} + +/// Tests that a transport is added on a candidate from the given addresses. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_add_transport_from_candidates() -> Result<()> { + let mut tcm = TestContextManager::new(); + let t = &tcm.unconfigured().await; + mark_defaults_tried(t, time()).await?; + let addrs_from_qr = [ + "alice@example.org".to_string(), + "bob@example.org".to_string(), + ]; + let skip_network = true; + add_relay_candidates(t, &addrs_from_qr).await?; + add_transport_from_candidates(t, skip_network).await?; + + let transports = t.list_transports().await?; + assert_eq!(transports.len(), 1); + assert!(transports[0].addr.ends_with("@example.org")); + assert_eq!( + t.get_config(Config::Addr).await?, + Some(transports[0].addr.clone()) + ); + let untried = untried_relay_candidates(t).await?; + assert_eq!(untried, ["example.org"]); + + Ok(()) +} + +/// Tests correct add_transport_from_candidates error handling. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_add_transport_from_candidates_failure() -> Result<()> { + let mut tcm = TestContextManager::new(); + let t = &tcm.unconfigured().await; + mark_defaults_tried(t, time()).await?; + save_relay_candidates(t, &["bad host", "worse host"], 0).await?; + + let skip_network = false; + let err = add_transport_from_candidates(t, skip_network) + .await + .unwrap_err(); + assert!(format!("{err:#}").contains("Bad email-address")); + assert!(!t.is_configured().await?); + let untried = untried_relay_candidates(t).await?; + assert_eq!(untried, ["bad host", "worse host"]); + t.assert_warns_or_errors(&[ + "Failed to add relay bad host", + "Failed to add relay worse host", + ]) + .await; + + Ok(()) +} + +async fn untried_relay_candidates(t: &TestContext) -> Result> { + t.sql + .query_map_vec( + "SELECT host FROM relay_candidates WHERE last_tried=0 ORDER BY host", + (), + |row| Ok(row.get(0)?), + ) + .await +} + +async fn save_relay_candidates(t: &TestContext, hosts: &[&str], last_tried: i64) -> Result<()> { + t.sql + .transaction(|tx| { + for host in hosts { + save_relay_candidate(tx, host, last_tried)?; + } + Ok(()) + }) + .await +} + +/// Keeps the default relays out of `triable_relay_candidates()`. +async fn mark_defaults_tried(t: &TestContext, now: i64) -> Result<()> { + save_relay_candidates(t, DEFAULT_RELAY_CANDIDATES, now).await +} + +/// Tests that saving a candidate overwrites its stored timestamp. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_save_relay_candidate() -> Result<()> { + let mut tcm = TestContextManager::new(); + let t = &tcm.unconfigured().await; + let now = time(); + + for last_tried in [0, now, 0] { + t.sql + .transaction(|tx| save_relay_candidate(tx, "relay.example", last_tried)) + .await?; + let stored: Option = t + .sql + .query_get_value( + "SELECT last_tried FROM relay_candidates WHERE host=?", + ("relay.example",), + ) + .await?; + assert_eq!(stored, Some(last_tried)); + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_triable_relay_candidates_single() -> Result<()> { let t = &TestContext::new_alice().await; enable_config(t).await; let now = time(); - t.sql.execute("DELETE FROM relay_candidates", ()).await?; + mark_defaults_tried(t, now).await?; - // This host should be returned by load_relay_candidates(): - t.sql - .execute( - "INSERT INTO relay_candidates (host, last_tried) VALUES (?, ?)", - ("never_tried.example", 0), - ) - .await?; + save_relay_candidates(t, &["never_tried.example", "example.org"], 0).await?; + save_relay_candidates(t, &["recent.example"], now).await?; - // This host was recently tried and should not be returned: - t.sql - .execute( - "INSERT INTO relay_candidates (host, last_tried) VALUES (?, ?)", - ("recent.example", now), - ) - .await?; - - // This host is already in use (alice@example.org) and should not be returned: - t.sql - .execute( - "INSERT INTO relay_candidates (host, last_tried) VALUES (?, ?)", - ("example.org", 0), - ) - .await?; - - let candidates = load_relay_candidates(t, now).await?; + let candidates = triable_relay_candidates(t, now).await?; assert_eq!(candidates, vec!["never_tried.example".to_string()]); @@ -44,22 +146,15 @@ async fn test_load_relay_candidates_single() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_load_relay_candidates_multiple() -> Result<()> { +async fn test_triable_relay_candidates_multiple() -> Result<()> { let t = &TestContext::new().await; enable_config(t).await; let now = time(); - t.sql.execute("DELETE FROM relay_candidates", ()).await?; - for host in ["a.example", "b.example", "c.example"] { - t.sql - .execute( - "INSERT INTO relay_candidates (host, last_tried) VALUES (?, ?)", - (host, 0), - ) - .await?; - } + mark_defaults_tried(t, now).await?; + save_relay_candidates(t, &["a.example", "b.example", "c.example"], 0).await?; - let mut candidates = load_relay_candidates(t, now).await?; + let mut candidates = triable_relay_candidates(t, now).await?; candidates.sort(); assert_eq!( @@ -160,13 +255,8 @@ async fn test_maybe_add_additional_relays_add_one() -> Result<()> { enable_config(t).await; let now = time(); - t.sql.execute("DELETE FROM relay_candidates", ()).await?; - t.sql - .execute( - "INSERT INTO relay_candidates (host, last_tried) VALUES (?, ?)", - ("relay.example", 0), - ) - .await?; + mark_defaults_tried(t, now).await?; + save_relay_candidates(t, &["relay.example"], 0).await?; let transports_before = t.count_transports().await?; @@ -189,15 +279,8 @@ async fn test_maybe_add_additional_relays_add_multiple() -> Result<()> { enable_config(t).await; let now = time(); - t.sql.execute("DELETE FROM relay_candidates", ()).await?; - for host in ["a.example", "b.example", "c.example", "d.example"] { - t.sql - .execute( - "INSERT INTO relay_candidates (host, last_tried) VALUES (?, ?)", - (host, 0), - ) - .await?; - } + mark_defaults_tried(t, now).await?; + save_relay_candidates(t, &["a.example", "b.example", "c.example", "d.example"], 0).await?; let skip_network = true; let relay_added = maybe_add_additional_relays_inner(t, skip_network).await?; @@ -218,14 +301,9 @@ async fn test_maybe_add_additional_relays_failure() -> Result<()> { enable_config(t).await; let now = time(); - t.sql.execute("DELETE FROM relay_candidates", ()).await?; + mark_defaults_tried(t, now).await?; for i in 1..10 { - t.sql - .execute( - "INSERT INTO relay_candidates (host, last_tried) VALUES (?, ?)", - (format!("{i}.invalid.example"), 0), - ) - .await?; + save_relay_candidates(t, &[format!("{i}.invalid.example").as_str()], 0).await?; } let transports_before = t.count_transports().await?; @@ -254,7 +332,7 @@ async fn test_maybe_add_additional_relays_failure() -> Result<()> { // ...but not all, because there might be many relay candidates // and we don't want to try all of them in a single call: - assert_eq!(load_relay_candidates(t, now).await?.is_empty(), false); + assert_eq!(triable_relay_candidates(t, now).await?.is_empty(), false); t.assert_warns_or_errors(&[ "DNS lookup with memory cache failure", diff --git a/src/configure.rs b/src/configure.rs index 71dd55152..efdb4bcb6 100644 --- a/src/configure.rs +++ b/src/configure.rs @@ -31,7 +31,7 @@ use crate::login_param::EnteredCertificateChecks; pub use crate::login_param::EnteredLoginParam; use crate::net::proxy::ProxyConfig; use crate::provider::{self, Protocol, Socket}; -use crate::qr::{login_param_from_account_qr, login_param_from_login_qr}; +use crate::qr::{Qr, check_qr, login_param_from_account_qr, login_param_from_login_qr}; use crate::smtp::Smtp; use crate::sync::Sync::Nosync; use crate::tools::time; @@ -40,7 +40,7 @@ use crate::transport::{ ConnectionCandidate, delete_transport_row, maybe_update_sending_transport, purge_transport_caches, send_sync_transports, transport_addrs, }; -use crate::{EventType, stock_str}; +use crate::{EventType, autorelay, stock_str}; /// Maximum number of relays. /// @@ -190,6 +190,59 @@ impl Context { Ok(()) } + /// Adds an initial transport on a randomly chosen chatmail relay + /// and lets the profile add further ones in the background. + /// + /// A `DCACCOUNT:` or `DCLOGIN:` `qr` code adds a single transport + /// while securejoin codes add the inviter's relays to the candidates. + /// + /// Does nothing if the profile already has a transport. + pub async fn init_transports(&self, qr: Option<&str>) -> Result<()> { + if self.is_configured().await? { + return Ok(()); + } + if let Some(qr) = qr { + match check_qr(self, qr).await? { + Qr::Account { .. } | Qr::Login { .. } => { + return self.add_transport_from_qr(qr).await; + } + Qr::AskVerifyContact { addrs, .. } + | Qr::AskVerifyGroup { addrs, .. } + | Qr::AskJoinBroadcast { addrs, .. } => { + autorelay::add_relay_candidates(self, &addrs).await? + } + _ => bail!("QR code does not contain a relay"), + } + } + + let cancel_channel = self.alloc_ongoing().await?; + let skip_network = false; + let res = autorelay::add_transport_from_candidates(self, skip_network) + .race(cancel_channel.recv().map(|_| Err(format_err!("Canceled")))) + .await; + self.free_ongoing().await; + + let configured = self.is_configured().await?; + match res { + Ok(()) => {} + Err(err) if configured => { + warn!( + self, + "Onboarding interrupted after adding a transport: {err:#}." + ); + } + Err(err) => { + let error_msg = stock_str::configuration_failed(self, &format!("{err:#}")); + progress!(self, 0, Some(error_msg.clone())); + bail!(error_msg); + } + } + self.set_config_bool(Config::Autorelay, true).await?; + progress!(self, 1000); + self.start_io().await; + Ok(()) + } + /// Returns the list of all email accounts that are used as a transport in the current profile. /// Use [Self::add_or_update_transport()] to add or change a transport /// and [Self::delete_transport()] to delete a transport. @@ -690,6 +743,26 @@ mod tests { Ok(()) } + /// Tests init_transports on a bad code and on a configured profile. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_init_transports() -> Result<()> { + let mut tcm = TestContextManager::new(); + let t = &tcm.unconfigured().await; + assert!(t.init_transports(Some("not a qr code")).await.is_err()); + assert!(!t.is_configured().await?); + + let alice = &tcm.alice().await; + let invite = "openpgp4fpr:79252762C34C5096AF57958F4FC3D21A81B0F0A7#a=cli%40invite.example&i=TbnwJ6lSvD5&s=0ejvbdFSQxB"; + alice.init_transports(Some(invite)).await?; + let candidates = alice + .sql + .count("SELECT COUNT(*) FROM relay_candidates", ()) + .await?; + assert_eq!(candidates, 0); + + Ok(()) + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_get_configured_param() -> Result<()> { let t = &TestContext::new().await; diff --git a/src/qr.rs b/src/qr.rs index 7297c6325..2ac9522a1 100644 --- a/src/qr.rs +++ b/src/qr.rs @@ -13,6 +13,7 @@ use serde::Deserialize; use crate::autorelay::login_param_from_host; use crate::config::Config; +use crate::configure::MAX_RELAYS; use crate::contact::{Contact, ContactId, Origin}; use crate::context::Context; use crate::key::Fingerprint; @@ -500,7 +501,8 @@ async fn decode_openpgp(context: &Context, qr: &str) -> Result { addrs.push(normalize_address(primary_addr)?); }; if let Some(secondary_addrs_raw) = param.get("r") { - for secondary_address in secondary_addrs_raw.split(',') { + let max_secondary = MAX_RELAYS.saturating_sub(addrs.len()); + for secondary_address in secondary_addrs_raw.split(',').take(max_secondary) { addrs.push(normalize_address(secondary_address)?) } } diff --git a/src/qr/qr_tests.rs b/src/qr/qr_tests.rs index 3db74ca1c..4b0884bc8 100644 --- a/src/qr/qr_tests.rs +++ b/src/qr/qr_tests.rs @@ -361,6 +361,23 @@ async fn test_decode_openpgp_secure_join() -> Result<()> { bail!("Wrong QR code type"); } + // A bad invite code must not be able to steer us onto arbitrarily many relays. + let relays: Vec = (0..20).map(|i| format!("cli%40r{i}.example.org")).collect(); + let qr = check_qr( + &ctx.ctx, + &format!( + "openpgp4fpr:79252762C34C5096AF57958F4FC3D21A81B0F0A7#a=cli%40deltachat.de&r={}&i=TbnwJ6lSvD5&s=0ejvbdFSQxB", + relays.join(",") + ), + ) + .await?; + + if let Qr::AskVerifyContact { addrs, .. } = qr { + assert_eq!(addrs.len(), MAX_RELAYS); + } else { + bail!("Wrong QR code type"); + } + Ok(()) } diff --git a/src/sql/migrations.rs b/src/sql/migrations.rs index 1b8870853..7836c4b0e 100644 --- a/src/sql/migrations.rs +++ b/src/sql/migrations.rs @@ -2673,6 +2673,13 @@ CREATE TABLE smtp2 ( .await?; } + inc_and_check(&mut migration_version, 167)?; + if dbversion < migration_version { + // The default relay candidates are no longer seeded into the table. + sql.execute_migration("DELETE FROM relay_candidates", migration_version) + .await?; + } + let new_version = sql .get_raw_config_int(VERSION_CFG) .await?