Skip to main content

tuwunel_service/migrations/
mod.rs

1//! One-time database migrations.
2//!
3//! A fresh database is stamped current and a legacy database is walked
4//! through the named migrations, once the version and server name gates
5//! decide it is safe to touch.
6
7use std::{cmp::Ordering, time::Duration};
8
9use futures::{FutureExt, StreamExt, TryStreamExt};
10use ruma::{OwnedUserId, ServerName, UserId};
11use tokio::time::sleep;
12use tuwunel_core::{
13	Err, Result, err, format_small_string, info,
14	itertools::Itertools,
15	result::NotFound,
16	smallstr::SmallString,
17	utils::{BoolExt, ReadyExt, TryReadyExt},
18	warn,
19};
20use tuwunel_database::Deserialized;
21
22use self::{
23	account_status::migrate_account_status,
24	clear_servername_status::clear_servername_status,
25	clear_state_local_error_memos::clear_state_local_error_memos,
26	email_bindings::migrate_email_bindings,
27	fix_bad_double_separator_in_state_cache::fix_bad_double_separator_in_state_cache,
28	fix_hashed_sentinel_passwords::fix_hashed_sentinel_passwords,
29	fix_readreceiptid_readreceipt_duplicates::fix_readreceiptid_readreceipt_duplicates,
30	fix_referencedevents_missing_sep::fix_referencedevents_missing_sep,
31	import_conduit_knocks::import_conduit_knocks,
32	injectivity::{fix as fix_injectivity, mark_clean as mark_clean_injectivity},
33	migrate_media::migrate_media,
34	migrate_profile_keys::migrate_profile_keys,
35	rebuild_roomid_tscount_pducount::rebuild_roomid_tscount_pducount,
36	remove_remote_media_userid::remove_remote_media_userid,
37	retroactively_fix_bad_data_from_roomuserid_joined::retroactively_fix_bad_data_from_roomuserid_joined,
38	split_conduit_highlight_counts::split_conduit_highlight_counts,
39	token_expiry::{migrate_token_expiry, restore_token_expiry},
40	upgrade_legacy_mediaid_user::upgrade_legacy_mediaid_user,
41};
42use crate::Services;
43
44mod account_status;
45mod clear_servername_status;
46mod clear_state_local_error_memos;
47mod conduit;
48mod email_bindings;
49mod fix_bad_double_separator_in_state_cache;
50mod fix_hashed_sentinel_passwords;
51mod fix_readreceiptid_readreceipt_duplicates;
52mod fix_referencedevents_missing_sep;
53mod import_conduit_knocks;
54mod injectivity;
55mod migrate_media;
56mod migrate_profile_keys;
57mod moderation;
58mod rebuild_roomid_tscount_pducount;
59mod remove_remote_media_userid;
60mod retroactively_fix_bad_data_from_roomuserid_joined;
61mod split_conduit_highlight_counts;
62mod token_expiry;
63mod upgrade_legacy_mediaid_user;
64
65#[cfg(test)]
66mod tests;
67
68/// The current schema version.
69/// - If database is opened at greater version we reject with error. The
70///   software must be updated for backward-incompatible changes.
71/// - If database is opened at lesser version we apply migrations up to this.
72///   Note that named-feature migrations may also be performed when opening at
73///   equal or lesser version. These are expected to be backward-compatible.
74pub(crate) const DATABASE_VERSION: u64 = 17;
75
76const SERVER_NAME_KEY: &[u8] = b"server_name";
77
78const FORCE_MIGRATION_DELAY: Duration = Duration::from_secs(15);
79
80const CLEAR_STATE_LOCAL_ERROR_MEMOS: &str = "clear_state_local_error_memos";
81
82/// A marker written by a sibling conduwuit-lineage server but never by tuwunel.
83/// Its presence identifies a foreign database at a higher schema number even
84/// after tuwunel has stamped its own `server_name`, so a database opened by
85/// both servers in turn keeps booting rather than being refused as too new.
86const FOREIGN_LINEAGE_MARKER: &[u8] = b"populate_userroomid_leftstate_table";
87
88/// Inline budget for a local user id assembled from a foreign localpart.
89type UserIdBuf = SmallString<[u8; 48]>;
90
91pub(crate) async fn migrations(services: &Services) -> Result {
92	if services.config.force_migration {
93		warn!(
94			delay = ?FORCE_MIGRATION_DELAY,
95			"The force_migration option is set. THIS IS NOT INTENDED TO BE USED UNDER ANY \
96			 NORMAL CIRCUMSTANCES AND YOU MAY BE CORRUPTING YOUR DATABASE BY PROCEEDING. \
97			 Remove force_migration from the configuration to clear this warning; startup \
98			 continues after the delay."
99		);
100
101		sleep(FORCE_MIGRATION_DELAY).await;
102	}
103
104	if !services.config.database_migrations {
105		if !marker_present(services, CLEAR_STATE_LOCAL_ERROR_MEMOS).await? {
106			return Err!(Config(
107				"database_migrations",
108				"Local state memo invalidation is pending. Enable database_migrations for one \
109				 startup, then this setting may be disabled again."
110			));
111		}
112
113		warn!("Skipping database migrations due to configuration...");
114		return Ok(());
115	}
116
117	// A stop before any step ran leaves nothing to resume from.
118	services.server.check_running()?;
119
120	let users_count = services.users.count().await;
121	if users_count == 0 {
122		return fresh(services).await;
123	}
124
125	// Computed before check_server_name backfills SERVER_NAME_KEY, which would
126	// otherwise mask a Conduit-lineage database (it carries no foreign marker).
127	let foreign_lineage = is_foreign_lineage(services).await;
128
129	check_database_version(services, foreign_lineage).await?;
130	check_server_name(services).await?;
131
132	services.server.check_running()?;
133
134	// Repairs residue rather than the schema, so it sits behind the gates
135	// that can still refuse this database.
136	fix_injectivity(services).await?;
137
138	let migrated = migrate(services, foreign_lineage).await;
139
140	services.server.progress.end();
141
142	migrated.inspect_err(|error| {
143		if error.is_interrupted() {
144			warn!(
145				"Stopped during database migrations. The steps that completed are recorded; the \
146				 rest run on the next start."
147			);
148		}
149	})
150}
151
152/// Whether the database comes from a foreign (non-tuwunel) lineage: it predates
153/// our SERVER_NAME_KEY stamp, or carries a conduwuit-lineage migration marker
154/// that persists even after we stamp ours. Must be read before the server_name
155/// backfill, which removes the first signal.
156async fn is_foreign_lineage(services: &Services) -> bool {
157	let global = &services.db["global"];
158
159	global.get(SERVER_NAME_KEY).await.is_not_found()
160		|| global.get(FOREIGN_LINEAGE_MARKER).await.is_ok()
161}
162
163/// Gate the discovered schema version before migrations and the server_name
164/// backfill run. The integer is comparable only within tuwunel's own lineage; a
165/// foreign database (Conduit and forks) numbers schema on a colliding ladder
166/// and is recognized as foreign by [`is_foreign_lineage`], so its number is not
167/// gated. Within our lineage a version below 13 is refused as unmigratable and
168/// one above this build as too new to open safely; force_migration overrides
169/// the latter for a deliberate downgrade.
170async fn check_database_version(services: &Services, foreign_lineage: bool) -> Result {
171	let discovered = services.globals.db.database_version().await;
172
173	if discovered < 13 {
174		return Err!(Database("Database schema version {discovered} is no longer supported"));
175	}
176
177	if discovered > DATABASE_VERSION && !foreign_lineage && !services.config.force_migration {
178		return Err!(Database(
179			"Database schema version {discovered} is newer than this build supports \
180			 ({DATABASE_VERSION}). Upgrade tuwunel to a build supporting this database."
181		));
182	}
183
184	Ok(())
185}
186
187/// Matrix resource ownership is based on the server name; changing it
188/// requires recreating the database from scratch. The marker is stamped
189/// once in fresh(); pre-marker databases are backfilled by probing for
190/// any user from the configured server.
191async fn check_server_name(services: &Services) -> Result {
192	let server_name = &services.server.name;
193
194	let existing = services.db["global"]
195		.get(SERVER_NAME_KEY)
196		.await
197		.deserialized::<String>();
198
199	match existing {
200		| Err(_) => backfill_server_name(services).await,
201		| Ok(existing) if existing.eq(server_name) => Ok(()),
202		| Ok(existing) => Err!(Database(
203			"Database belongs to {existing}; configured server name is {server_name}. Cannot \
204			 reuse."
205		)),
206	}
207}
208
209/// Stamp the marker on a database that pre-dates SERVER_NAME_KEY by probing
210/// for any user from the configured server. If none, the database belongs
211/// to a different server and reuse is refused.
212async fn backfill_server_name(services: &Services) -> Result {
213	let server_name = &services.server.name;
214
215	services
216		.users
217		.stream()
218		.ready_any(|user_id| services.globals.user_is_local(user_id))
219		.await
220		.into_option()
221		.ok_or_else(|| {
222			err!(Database(
223				"Database has no users from {server_name}; refusing to reuse with this \
224				 server_name."
225			))
226		})?;
227
228	services.db["global"].insert(SERVER_NAME_KEY, server_name.as_str());
229	info!(%server_name, "Stamped server_name marker on upgraded database");
230
231	Ok(())
232}
233
234async fn fresh(services: &Services) -> Result {
235	let db = &services.db;
236
237	services
238		.globals
239		.db
240		.bump_database_version(DATABASE_VERSION);
241
242	db["global"].insert(SERVER_NAME_KEY, services.server.name.as_str());
243	db["global"].insert("feat_sha256_media", []);
244	db["global"].insert("fix_pdu_missing_room_id", []);
245	db["global"].insert("fix_bad_double_separator_in_state_cache", []);
246	db["global"].insert("retroactively_fix_bad_data_from_roomuserid_joined", []);
247	db["global"].insert("fix_referencedevents_missing_sep", []);
248	db["global"].insert("fix_readreceiptid_readreceipt_duplicates", []);
249	db["global"].insert("fix_hashed_sentinel_passwords", []);
250	db["global"].insert("upgrade_legacy_mediaid_user", []);
251	db["global"].insert("remove_remote_media_userid", []);
252	db["global"].insert("rebuild_roomid_tscount_pducount", []);
253	db["global"].insert("rebuild_relatesto_typed", []);
254	db["global"].insert("migrate_profile_keys_to_useridprofilekey", []);
255	db["global"].insert("rebuild_thread_activity", []);
256	db["global"].insert("clear_servername_status", []);
257	db["global"].insert(CLEAR_STATE_LOCAL_ERROR_MEMOS, []);
258	db["global"].insert("adopt_foreign_account_status", []);
259	db["global"].insert("adopt_foreign_email_bindings", []);
260	db["global"].insert(token_expiry::RESTORE_MARKER, []);
261	db["global"].insert(token_expiry::ADOPT_MARKER, []);
262	mark_clean_injectivity(services);
263
264	// Create the admin room and server user on first run
265	if services.config.create_admin_room {
266		crate::admin::create_admin_room(services)
267			.boxed()
268			.await?;
269	}
270
271	warn!("Created new RocksDB database with version {DATABASE_VERSION}");
272
273	Ok(())
274}
275
276/// Apply any migrations
277async fn migrate(services: &Services, foreign_lineage: bool) -> Result {
278	let db = &services.db;
279
280	services.server.check_running()?;
281
282	let target_version = DATABASE_VERSION;
283	let discovered = services.globals.db.database_version().await;
284
285	// Claim our schema version up front when importing a foreign database
286	// numbered above ours (e.g. Conduit at 18). Stamping only at the end would
287	// leave an aborted import unbootable: the server_name backfill has already
288	// run, so a restart no longer sees the database as foreign and the version
289	// gate refuses it. The per-step markers below remain the real idempotency
290	// gates, so an aborted import still resumes where it left off.
291	if foreign_lineage && discovered > target_version {
292		services
293			.globals
294			.db
295			.bump_database_version(target_version);
296	}
297
298	migrate_media(services).await?;
299
300	if pending(services, "fix_pdu_missing_room_id").await? {
301		conduit::migrate_conduit_pdus(services).await?;
302		db["global"].insert("fix_pdu_missing_room_id", []);
303	}
304
305	import_conduit_knocks(services).await?;
306	split_conduit_highlight_counts(services).await?;
307
308	// The next two repairs fix a conduwuit-era roomuserid_joined bug Conduit
309	// never had; record them done for a Conduit database instead of running.
310	if db
311		.open_cf("servernamemediaid_metadata")?
312		.is_some()
313	{
314		db["global"].insert("fix_bad_double_separator_in_state_cache", []);
315		db["global"].insert("retroactively_fix_bad_data_from_roomuserid_joined", []);
316	}
317
318	if pending(services, "fix_bad_double_separator_in_state_cache").await? {
319		fix_bad_double_separator_in_state_cache(services).await?;
320	}
321
322	if pending(services, "retroactively_fix_bad_data_from_roomuserid_joined").await? {
323		retroactively_fix_bad_data_from_roomuserid_joined(services).await?;
324	}
325
326	if pending(services, "fix_referencedevents_missing_sep").await? {
327		fix_referencedevents_missing_sep(services).await?;
328	}
329
330	if pending(services, "fix_readreceiptid_readreceipt_duplicates").await? {
331		fix_readreceiptid_readreceipt_duplicates(services).await?;
332	}
333
334	if pending(services, "fix_hashed_sentinel_passwords").await? {
335		fix_hashed_sentinel_passwords(services).await?;
336	}
337
338	if pending(services, "upgrade_legacy_mediaid_user").await? {
339		upgrade_legacy_mediaid_user(services).await?;
340	}
341
342	if pending(services, "remove_remote_media_userid").await? {
343		remove_remote_media_userid(services).await?;
344	}
345
346	if pending(services, "rebuild_roomid_tscount_pducount").await? {
347		rebuild_roomid_tscount_pducount(services).await?;
348	}
349
350	if pending(services, "rebuild_relatesto_typed").await? {
351		services
352			.pdu_metadata
353			.rebuild_typed_relations()
354			.await?;
355
356		db["global"].insert("rebuild_relatesto_typed", []);
357	}
358
359	if pending(services, "migrate_profile_keys_to_useridprofilekey").await? {
360		migrate_profile_keys(services).await?;
361	}
362
363	if pending(services, "rebuild_thread_activity").await? {
364		services.threads.rebuild_thread_activity().await?;
365
366		db["global"].insert("rebuild_thread_activity", []);
367	}
368
369	if pending(services, "clear_servername_status").await? {
370		clear_servername_status(services).await?;
371	}
372
373	if pending(services, CLEAR_STATE_LOCAL_ERROR_MEMOS).await? {
374		clear_state_local_error_memos(services).await?;
375	}
376
377	services.server.check_running()?;
378
379	// Non-destructive and idempotent, so it runs every boot rather than once: a
380	// suspension added by an origin server after a prior tuwunel boot still
381	// carries on the next one.
382	services
383		.server
384		.progress
385		.begin("migrate_moderation");
386	moderation::migrate_moderation(services).await?;
387
388	if pending(services, "adopt_foreign_account_status").await? {
389		migrate_account_status(services).await?;
390
391		db["global"].insert("adopt_foreign_account_status", []);
392	}
393
394	if pending(services, "adopt_foreign_email_bindings").await? {
395		migrate_email_bindings(services).await?;
396
397		db["global"].insert("adopt_foreign_email_bindings", []);
398	}
399
400	// The restore hands adopted rows back to the adoption, so it runs first.
401	until_finished(services, token_expiry::RESTORE_MARKER, restore_token_expiry).await?;
402	until_finished(services, token_expiry::ADOPT_MARKER, migrate_token_expiry).await?;
403
404	services.server.check_running()?;
405
406	// A newer same-lineage database was already refused; stamping ours is safe. A
407	// foreign import above our version was already stamped down before the import
408	// ran, so this is a no-op for it.
409	services
410		.globals
411		.db
412		.bump_database_version(target_version);
413
414	match discovered.cmp(&target_version) {
415		| Ordering::Less =>
416			info!("Database: migrated schema version from {discovered} to {target_version}."),
417		| Ordering::Greater => warn!(
418			"Database: stamped schema version {target_version} over a higher discovered version \
419			 {discovered} (forced downgrade or foreign import)."
420		),
421		| Ordering::Equal => {},
422	}
423
424	warn_forbidden_names(services).await?;
425
426	info!("Loaded RocksDB database with schema version {DATABASE_VERSION}");
427
428	Ok(())
429}
430
431/// Runs a pass whose work may wait on a condition outside the database.
432///
433/// The marker is stamped only once the pass reports it finished, so a pass that
434/// waits runs again on a later boot.
435async fn until_finished<F>(services: &Services, marker: &'static str, pass: F) -> Result
436where
437	F: AsyncFnOnce(&Services) -> Result<bool>,
438{
439	if pending(services, marker).await? {
440		let finished = pass(services).await?;
441
442		if finished {
443			services.db["global"].insert(marker, []);
444		}
445	}
446
447	Ok(())
448}
449
450/// Warns about existing names the configuration now forbids.
451///
452/// The patterns are advisory rather than enforced retroactively, so a match
453/// only names the user or the alias in the log. Neither scan runs when its own
454/// pattern list is empty or once shutdown begins.
455async fn warn_forbidden_names(services: &Services) -> Result {
456	services.server.check_running()?;
457
458	if !services.config.forbidden_usernames.is_empty() {
459		services
460			.server
461			.progress
462			.begin("scan_forbidden_usernames");
463
464		services
465			.users
466			.stream()
467			.map(|user_id| {
468				services
469					.server
470					.check_running()
471					.map(|()| user_id.to_owned())
472			})
473			.try_filter_map(async |user_id| {
474				Ok(services
475					.users
476					.is_active_local(&user_id)
477					.await
478					.then_some(user_id))
479			})
480			.ready_try_filter_map(|user_id| {
481				let patterns = &services.config.forbidden_usernames;
482				let matches = patterns.matches(user_id.localpart());
483				let matched_patterns = matches
484					.iter()
485					.map(|pattern_index| &patterns.patterns()[pattern_index])
486					.join(", ");
487
488				Ok(matches
489					.matched_any()
490					.then_some((user_id, matched_patterns)))
491			})
492			.ready_try_for_each(|(user_id, matched_patterns)| {
493				warn!("User {user_id} matches forbidden username patterns: {matched_patterns}");
494				Ok(())
495			})
496			.await?;
497	}
498
499	services.server.check_running()?;
500
501	if !services.config.forbidden_alias_names.is_empty() {
502		services
503			.server
504			.progress
505			.begin("scan_forbidden_alias_names");
506
507		services
508			.metadata
509			.iter_ids()
510			.map(|room_id| {
511				services
512					.server
513					.check_running()
514					.map(|()| room_id.to_owned())
515			})
516			.try_for_each(async |room_id| {
517				services
518					.alias
519					.local_aliases_for_room(&room_id)
520					.map(|room_alias| {
521						services
522							.server
523							.check_running()
524							.map(|()| room_alias)
525					})
526					.ready_try_for_each(|room_alias| {
527						let patterns = &services.config.forbidden_alias_names;
528						let matches = patterns.matches(room_alias.alias());
529						let matched_patterns = matches
530							.iter()
531							.map(|pattern_index| &patterns.patterns()[pattern_index])
532							.join(", ");
533
534						if matches.matched_any() {
535							warn!(
536								"Room {room_id} with alias {room_alias} matches the following \
537								 forbidden alias name patterns: {matched_patterns}"
538							);
539						}
540
541						Ok(())
542					})
543					.await
544			})
545			.await?;
546	}
547
548	Ok(())
549}
550
551/// Whether a named migration step still needs to run, refusing once shutdown
552/// begins.
553///
554/// A step gate is the safe place to observe a stop request: every step that has
555/// already run stamped its marker, so the ladder is consistent here and the
556/// remaining steps resume on the next start. A step about to run is also named
557/// as the phase in flight, so the operator sees which one a long boot is
558/// spending its time on.
559async fn pending(services: &Services, marker: &'static str) -> Result<bool> {
560	services.server.check_running()?;
561
562	let pending = !marker_present(services, marker).await?;
563
564	if pending {
565		services.server.progress.begin(marker);
566	}
567
568	Ok(pending)
569}
570
571/// Whether a migration step has stamped its marker.
572///
573/// Only a missing marker reads as absent. A read that fails propagates, so a
574/// step is never skipped on the strength of a failed read.
575pub(super) async fn marker_present(services: &Services, marker: &str) -> Result<bool> {
576	services.db["global"]
577		.get(marker)
578		.await
579		.optional()
580		.inspect_err(|error| warn!(%marker, %error, "Migration marker failed to read"))
581		.map(|stamp| stamp.is_some())
582}
583
584/// Assembles a local user id from a localpart a foreign column records.
585///
586/// The id is formatted into an inline buffer and parsed from that slice, which
587/// keeps a short id in inline storage; parsing against a server name instead
588/// routes through an over-allocated `String` and spills to the heap.
589pub(crate) fn local_user_id(localpart: &str, server_name: &ServerName) -> Option<OwnedUserId> {
590	let user_id: UserIdBuf = format_small_string!("@{localpart}:{server_name}");
591
592	UserId::parse(user_id.as_str()).ok()
593}