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};
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},
18	warn,
19};
20use tuwunel_database::Deserialized;
21
22use self::{
23	account_status::migrate_account_status,
24	clear_servername_status::clear_servername_status,
25	email_bindings::migrate_email_bindings,
26	fix_bad_double_separator_in_state_cache::fix_bad_double_separator_in_state_cache,
27	fix_hashed_sentinel_passwords::fix_hashed_sentinel_passwords,
28	fix_readreceiptid_readreceipt_duplicates::fix_readreceiptid_readreceipt_duplicates,
29	fix_referencedevents_missing_sep::fix_referencedevents_missing_sep,
30	import_conduit_knocks::import_conduit_knocks,
31	injectivity::{fix as fix_injectivity, mark_clean as mark_clean_injectivity},
32	migrate_media::migrate_media,
33	migrate_profile_keys::migrate_profile_keys,
34	rebuild_roomid_tscount_pducount::rebuild_roomid_tscount_pducount,
35	remove_remote_media_userid::remove_remote_media_userid,
36	retroactively_fix_bad_data_from_roomuserid_joined::retroactively_fix_bad_data_from_roomuserid_joined,
37	split_conduit_highlight_counts::split_conduit_highlight_counts,
38	upgrade_legacy_mediaid_user::upgrade_legacy_mediaid_user,
39};
40use crate::Services;
41
42mod account_status;
43mod clear_servername_status;
44mod conduit;
45mod email_bindings;
46mod fix_bad_double_separator_in_state_cache;
47mod fix_hashed_sentinel_passwords;
48mod fix_readreceiptid_readreceipt_duplicates;
49mod fix_referencedevents_missing_sep;
50mod import_conduit_knocks;
51mod injectivity;
52mod migrate_media;
53mod migrate_profile_keys;
54mod moderation;
55mod rebuild_roomid_tscount_pducount;
56mod remove_remote_media_userid;
57mod retroactively_fix_bad_data_from_roomuserid_joined;
58mod split_conduit_highlight_counts;
59mod upgrade_legacy_mediaid_user;
60
61#[cfg(test)]
62mod tests;
63
64/// The current schema version.
65/// - If database is opened at greater version we reject with error. The
66///   software must be updated for backward-incompatible changes.
67/// - If database is opened at lesser version we apply migrations up to this.
68///   Note that named-feature migrations may also be performed when opening at
69///   equal or lesser version. These are expected to be backward-compatible.
70pub(crate) const DATABASE_VERSION: u64 = 17;
71
72const SERVER_NAME_KEY: &[u8] = b"server_name";
73
74const FORCE_MIGRATION_DELAY: Duration = Duration::from_secs(15);
75
76/// A marker written by a sibling conduwuit-lineage server but never by tuwunel.
77/// Its presence identifies a foreign database at a higher schema number even
78/// after tuwunel has stamped its own `server_name`, so a database opened by
79/// both servers in turn keeps booting rather than being refused as too new.
80const FOREIGN_LINEAGE_MARKER: &[u8] = b"populate_userroomid_leftstate_table";
81
82/// Inline budget for a local user id assembled from a foreign localpart.
83type UserIdBuf = SmallString<[u8; 48]>;
84
85pub(crate) async fn migrations(services: &Services) -> Result {
86	if services.config.force_migration {
87		warn!(
88			delay = ?FORCE_MIGRATION_DELAY,
89			"The force_migration option is set. THIS IS NOT INTENDED TO BE USED UNDER ANY \
90			 NORMAL CIRCUMSTANCES AND YOU MAY BE CORRUPTING YOUR DATABASE BY PROCEEDING. \
91			 Remove force_migration from the configuration to clear this warning; startup \
92			 continues after the delay."
93		);
94
95		sleep(FORCE_MIGRATION_DELAY).await;
96	}
97
98	if !services.config.database_migrations {
99		warn!("Skipping database migrations due to configuration...");
100		return Ok(());
101	}
102
103	let users_count = services.users.count().await;
104	if users_count == 0 {
105		return fresh(services).await;
106	}
107
108	// Computed before check_server_name backfills SERVER_NAME_KEY, which would
109	// otherwise mask a Conduit-lineage database (it carries no foreign marker).
110	let foreign_lineage = is_foreign_lineage(services).await;
111
112	check_database_version(services, foreign_lineage).await?;
113	check_server_name(services).await?;
114
115	// Repairs residue rather than the schema, so it sits behind the gates
116	// that can still refuse this database.
117	fix_injectivity(services).await?;
118
119	migrate(services, foreign_lineage).await
120}
121
122/// Whether the database comes from a foreign (non-tuwunel) lineage: it predates
123/// our SERVER_NAME_KEY stamp, or carries a conduwuit-lineage migration marker
124/// that persists even after we stamp ours. Must be read before the server_name
125/// backfill, which removes the first signal.
126async fn is_foreign_lineage(services: &Services) -> bool {
127	let global = &services.db["global"];
128
129	global.get(SERVER_NAME_KEY).await.is_not_found()
130		|| global.get(FOREIGN_LINEAGE_MARKER).await.is_ok()
131}
132
133/// Gate the discovered schema version before migrations and the server_name
134/// backfill run. The integer is comparable only within tuwunel's own lineage; a
135/// foreign database (Conduit and forks) numbers schema on a colliding ladder
136/// and is recognized as foreign by [`is_foreign_lineage`], so its number is not
137/// gated. Within our lineage a version below 13 is refused as unmigratable and
138/// one above this build as too new to open safely; force_migration overrides
139/// the latter for a deliberate downgrade.
140async fn check_database_version(services: &Services, foreign_lineage: bool) -> Result {
141	let discovered = services.globals.db.database_version().await;
142
143	if discovered < 13 {
144		return Err!(Database("Database schema version {discovered} is no longer supported"));
145	}
146
147	if discovered > DATABASE_VERSION && !foreign_lineage && !services.config.force_migration {
148		return Err!(Database(
149			"Database schema version {discovered} is newer than this build supports \
150			 ({DATABASE_VERSION}). Upgrade tuwunel to a build supporting this database."
151		));
152	}
153
154	Ok(())
155}
156
157/// Matrix resource ownership is based on the server name; changing it
158/// requires recreating the database from scratch. The marker is stamped
159/// once in fresh(); pre-marker databases are backfilled by probing for
160/// any user from the configured server.
161async fn check_server_name(services: &Services) -> Result {
162	let server_name = &services.server.name;
163
164	let existing = services.db["global"]
165		.get(SERVER_NAME_KEY)
166		.await
167		.deserialized::<String>();
168
169	match existing {
170		| Err(_) => backfill_server_name(services).await,
171		| Ok(existing) if existing.eq(server_name) => Ok(()),
172		| Ok(existing) => Err!(Database(
173			"Database belongs to {existing}; configured server name is {server_name}. Cannot \
174			 reuse."
175		)),
176	}
177}
178
179/// Stamp the marker on a database that pre-dates SERVER_NAME_KEY by probing
180/// for any user from the configured server. If none, the database belongs
181/// to a different server and reuse is refused.
182async fn backfill_server_name(services: &Services) -> Result {
183	let server_name = &services.server.name;
184
185	services
186		.users
187		.stream()
188		.ready_any(|user_id| services.globals.user_is_local(user_id))
189		.await
190		.into_option()
191		.ok_or_else(|| {
192			err!(Database(
193				"Database has no users from {server_name}; refusing to reuse with this \
194				 server_name."
195			))
196		})?;
197
198	services.db["global"].insert(SERVER_NAME_KEY, server_name.as_str());
199	info!(%server_name, "Stamped server_name marker on upgraded database");
200
201	Ok(())
202}
203
204async fn fresh(services: &Services) -> Result {
205	let db = &services.db;
206
207	services
208		.globals
209		.db
210		.bump_database_version(DATABASE_VERSION);
211
212	db["global"].insert(SERVER_NAME_KEY, services.server.name.as_str());
213	db["global"].insert(b"feat_sha256_media", []);
214	db["global"].insert(b"fix_pdu_missing_room_id", []);
215	db["global"].insert(b"fix_bad_double_separator_in_state_cache", []);
216	db["global"].insert(b"retroactively_fix_bad_data_from_roomuserid_joined", []);
217	db["global"].insert(b"fix_referencedevents_missing_sep", []);
218	db["global"].insert(b"fix_readreceiptid_readreceipt_duplicates", []);
219	db["global"].insert(b"fix_hashed_sentinel_passwords", []);
220	db["global"].insert(b"upgrade_legacy_mediaid_user", []);
221	db["global"].insert(b"remove_remote_media_userid", []);
222	db["global"].insert(b"rebuild_roomid_tscount_pducount", []);
223	db["global"].insert(b"rebuild_relatesto_typed", []);
224	db["global"].insert(b"migrate_profile_keys_to_useridprofilekey", []);
225	db["global"].insert(b"rebuild_thread_activity", []);
226	db["global"].insert(b"clear_servername_status", []);
227	db["global"].insert(b"adopt_foreign_account_status", []);
228	db["global"].insert(b"adopt_foreign_email_bindings", []);
229	mark_clean_injectivity(services);
230
231	// Create the admin room and server user on first run
232	if services.config.create_admin_room {
233		crate::admin::create_admin_room(services)
234			.boxed()
235			.await?;
236	}
237
238	warn!("Created new RocksDB database with version {DATABASE_VERSION}");
239
240	Ok(())
241}
242
243/// Apply any migrations
244#[expect(clippy::too_many_lines)]
245async fn migrate(services: &Services, foreign_lineage: bool) -> Result {
246	let db = &services.db;
247
248	let target_version = DATABASE_VERSION;
249	let discovered = services.globals.db.database_version().await;
250
251	// Claim our schema version up front when importing a foreign database
252	// numbered above ours (e.g. Conduit at 18). Stamping only at the end would
253	// leave an aborted import unbootable: the server_name backfill has already
254	// run, so a restart no longer sees the database as foreign and the version
255	// gate refuses it. The per-step markers below remain the real idempotency
256	// gates, so an aborted import still resumes where it left off.
257	if foreign_lineage && discovered > target_version {
258		services
259			.globals
260			.db
261			.bump_database_version(target_version);
262	}
263
264	migrate_media(services).await?;
265
266	if db["global"]
267		.get(b"fix_pdu_missing_room_id")
268		.await
269		.is_not_found()
270	{
271		conduit::migrate_conduit_pdus(services).await?;
272		db["global"].insert(b"fix_pdu_missing_room_id", []);
273	}
274
275	import_conduit_knocks(services).await?;
276	split_conduit_highlight_counts(services).await?;
277
278	// The next two repairs fix a conduwuit-era roomuserid_joined bug Conduit
279	// never had; record them done for a Conduit database instead of running.
280	if db
281		.open_cf("servernamemediaid_metadata")?
282		.is_some()
283	{
284		db["global"].insert(b"fix_bad_double_separator_in_state_cache", []);
285		db["global"].insert(b"retroactively_fix_bad_data_from_roomuserid_joined", []);
286	}
287
288	if db["global"]
289		.get(b"fix_bad_double_separator_in_state_cache")
290		.await
291		.is_not_found()
292	{
293		fix_bad_double_separator_in_state_cache(services).await?;
294	}
295
296	if db["global"]
297		.get(b"retroactively_fix_bad_data_from_roomuserid_joined")
298		.await
299		.is_not_found()
300	{
301		retroactively_fix_bad_data_from_roomuserid_joined(services).await?;
302	}
303
304	if db["global"]
305		.get(b"fix_referencedevents_missing_sep")
306		.await
307		.is_not_found()
308	{
309		fix_referencedevents_missing_sep(services).await?;
310	}
311
312	if db["global"]
313		.get(b"fix_readreceiptid_readreceipt_duplicates")
314		.await
315		.is_not_found()
316	{
317		fix_readreceiptid_readreceipt_duplicates(services).await?;
318	}
319
320	if db["global"]
321		.get(b"fix_hashed_sentinel_passwords")
322		.await
323		.is_not_found()
324	{
325		fix_hashed_sentinel_passwords(services).await?;
326	}
327
328	if db["global"]
329		.get(b"upgrade_legacy_mediaid_user")
330		.await
331		.is_not_found()
332	{
333		upgrade_legacy_mediaid_user(services).await?;
334	}
335
336	if db["global"]
337		.get(b"remove_remote_media_userid")
338		.await
339		.is_not_found()
340	{
341		remove_remote_media_userid(services).await?;
342	}
343
344	if db["global"]
345		.get(b"rebuild_roomid_tscount_pducount")
346		.await
347		.is_not_found()
348	{
349		rebuild_roomid_tscount_pducount(services).await?;
350	}
351
352	if db["global"]
353		.get(b"rebuild_relatesto_typed")
354		.await
355		.is_not_found()
356	{
357		services
358			.pdu_metadata
359			.rebuild_typed_relations()
360			.await?;
361
362		db["global"].insert(b"rebuild_relatesto_typed", []);
363	}
364
365	if db["global"]
366		.get(b"migrate_profile_keys_to_useridprofilekey")
367		.await
368		.is_not_found()
369	{
370		migrate_profile_keys(services).await?;
371	}
372
373	if db["global"]
374		.get(b"rebuild_thread_activity")
375		.await
376		.is_not_found()
377	{
378		services.threads.rebuild_thread_activity().await?;
379
380		db["global"].insert(b"rebuild_thread_activity", []);
381	}
382
383	if db["global"]
384		.get(b"clear_servername_status")
385		.await
386		.is_not_found()
387	{
388		clear_servername_status(services).await?;
389	}
390
391	// Non-destructive and idempotent, so it runs every boot rather than once: a
392	// suspension added by an origin server after a prior tuwunel boot still
393	// carries on the next one.
394	moderation::migrate_moderation(services).await?;
395
396	if db["global"]
397		.get(b"adopt_foreign_account_status")
398		.await
399		.is_not_found()
400	{
401		migrate_account_status(services).await?;
402
403		db["global"].insert(b"adopt_foreign_account_status", []);
404	}
405
406	if db["global"]
407		.get(b"adopt_foreign_email_bindings")
408		.await
409		.is_not_found()
410	{
411		migrate_email_bindings(services).await?;
412
413		db["global"].insert(b"adopt_foreign_email_bindings", []);
414	}
415
416	// A newer same-lineage database was already refused; stamping ours is safe. A
417	// foreign import above our version was already stamped down before the import
418	// ran, so this is a no-op for it.
419	services
420		.globals
421		.db
422		.bump_database_version(target_version);
423
424	match discovered.cmp(&target_version) {
425		| Ordering::Less =>
426			info!("Database: migrated schema version from {discovered} to {target_version}."),
427		| Ordering::Greater => warn!(
428			"Database: stamped schema version {target_version} over a higher discovered version \
429			 {discovered} (forced downgrade or foreign import)."
430		),
431		| Ordering::Equal => {},
432	}
433
434	if !services.config.forbidden_usernames.is_empty() {
435		services
436			.users
437			.stream()
438			.filter(|user_id| services.users.is_active_local(user_id))
439			.ready_filter_map(|user_id| {
440				let patterns = &services.config.forbidden_usernames;
441				let matches = patterns.matches(user_id.localpart());
442				let matched = matches
443					.iter()
444					.map(|x| &patterns.patterns()[x])
445					.join(", ");
446
447				matches
448					.matched_any()
449					.then_some((user_id, matched))
450			})
451			.ready_for_each(|(user_id, matched)| {
452				warn!("User {user_id} matches forbidden username patterns: {matched:#?}");
453			})
454			.await;
455	}
456
457	if !services.config.forbidden_alias_names.is_empty() {
458		services
459			.metadata
460			.iter_ids()
461			.map(|room_id| {
462				services
463					.alias
464					.local_aliases_for_room(room_id)
465					.map(move |alias| (room_id, alias))
466			})
467			.flatten()
468			.ready_filter_map(|(room_id, room_alias)| {
469				let patterns = &services.config.forbidden_alias_names;
470				let matches = patterns.matches(room_alias.alias());
471				let matched = matches
472					.iter()
473					.map(|x| &patterns.patterns()[x])
474					.join(", ");
475
476				matches
477					.matched_any()
478					.then_some((room_id, room_alias, matched))
479			})
480			.ready_for_each(|(room_id, room_alias, matched)| {
481				warn!(
482					"Room {room_id} with alias {room_alias} matches the following forbidden \
483					 room name patterns: {matched}"
484				);
485			})
486			.boxed()
487			.await;
488	}
489
490	info!("Loaded RocksDB database with schema version {DATABASE_VERSION}");
491
492	Ok(())
493}
494
495/// Assembles a local user id from a localpart a foreign column records.
496///
497/// The id is formatted into an inline buffer and parsed from that slice, which
498/// keeps a short id in inline storage; parsing against a server name instead
499/// routes through an over-allocated `String` and spills to the heap.
500pub(crate) fn local_user_id(localpart: &str, server_name: &ServerName) -> Option<OwnedUserId> {
501	let user_id: UserIdBuf = format_small_string!("@{localpart}:{server_name}");
502
503	UserId::parse(user_id.as_str()).ok()
504}