tuwunel_service/migrations/
mod.rs1use 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
64pub(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
76const FOREIGN_LINEAGE_MARKER: &[u8] = b"populate_userroomid_leftstate_table";
81
82type 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 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 fix_injectivity(services).await?;
118
119 migrate(services, foreign_lineage).await
120}
121
122async 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
133async 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
157async 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
179async 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 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#[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 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 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 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 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
495pub(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}