syn2mas/synapse_reader/config/
mod.rs1mod oidc;
7
8use std::collections::BTreeMap;
9
10use camino::Utf8PathBuf;
11use chrono::{DateTime, Utc};
12use figment::providers::{Format, Yaml};
13use mas_config::{PasswordAlgorithm, PasswordHashingScheme};
14use rand::Rng;
15use serde::Deserialize;
16use sqlx::postgres::PgConnectOptions;
17use tracing::warn;
18use url::Url;
19
20pub use self::oidc::OidcProvider;
21
22#[derive(Deserialize)]
28#[expect(clippy::struct_excessive_bools)]
29pub struct Config {
30 pub database: DatabaseSection,
31
32 #[serde(default)]
33 pub password_config: PasswordSection,
34
35 pub bcrypt_rounds: Option<u32>,
36
37 #[serde(default)]
38 pub allow_guest_access: bool,
39
40 #[serde(default)]
41 pub enable_registration: bool,
42
43 #[serde(default)]
44 pub enable_registration_captcha: bool,
45 pub recaptcha_public_key: Option<String>,
46 pub recaptcha_private_key: Option<String>,
47
48 #[serde(default)]
51 pub enable_3pid_changes: Option<bool>,
52
53 #[serde(default = "default_true")]
54 enable_set_display_name: bool,
55
56 #[serde(default)]
57 pub user_consent: Option<UserConsentSection>,
58
59 #[serde(default)]
60 pub registrations_require_3pid: Vec<String>,
61
62 #[serde(default)]
63 pub registration_requires_token: bool,
64
65 pub registration_shared_secret: Option<String>,
66
67 #[serde(default)]
68 pub login_via_existing_session: EnableableSection,
69
70 #[serde(default)]
71 pub cas_config: EnableableSection,
72
73 #[serde(default)]
74 pub saml2_config: EnableableSection,
75
76 #[serde(default)]
77 pub jwt_config: EnableableSection,
78
79 #[serde(default)]
80 pub oidc_config: Option<OidcProvider>,
81
82 #[serde(default)]
83 pub oidc_providers: Vec<OidcProvider>,
84
85 pub server_name: String,
86
87 pub public_baseurl: Option<Url>,
88}
89
90impl Config {
91 pub fn load(
98 files: &[Utf8PathBuf],
99 ) -> Result<Config, Box<dyn std::error::Error + Send + Sync + 'static>> {
100 let mut figment = figment::Figment::new();
101 for file in files {
102 figment = figment.merge(Yaml::file(file));
107 }
108 let config = figment.extract::<Config>()?;
109 Ok(config)
110 }
111
112 #[must_use]
120 pub fn all_oidc_providers(&self) -> BTreeMap<String, OidcProvider> {
121 let mut out = BTreeMap::new();
122
123 if let Some(provider) = &self.oidc_config
124 && provider.has_required_fields()
125 {
126 let mut provider = provider.clone();
127 let idp_id = provider.idp_id.take().unwrap_or("oidc".to_owned());
129 provider.idp_id = Some(idp_id.clone());
130 out.insert(idp_id, provider);
131 }
132
133 for provider in &self.oidc_providers {
134 let mut provider = provider.clone();
135 let idp_id = match provider.idp_id.take() {
136 None => "oidc".to_owned(),
137 Some(idp_id) if idp_id == "oidc" => idp_id,
138 Some(idp_id) => format!("oidc-{idp_id}"),
140 };
141 provider.idp_id = Some(idp_id.clone());
142 out.insert(idp_id, provider);
143 }
144
145 out
146 }
147
148 #[must_use]
150 pub fn adjust_mas_config(
151 self,
152 mut mas_config: mas_config::RootConfig,
153 rng: &mut impl Rng,
154 now: DateTime<Utc>,
155 ) -> mas_config::RootConfig {
156 let providers = self.all_oidc_providers();
157 for provider in providers.into_values() {
158 let Some(mas_provider_config) = provider.into_mas_config(rng, now) else {
159 warn!("Could not convert OIDC provider to MAS config");
161 continue;
162 };
163
164 mas_config
165 .upstream_oauth2
166 .providers
167 .push(mas_provider_config);
168 }
169
170 if let Some(enable_3pid_changes) = self.enable_3pid_changes {
172 mas_config.account.email_change_allowed = enable_3pid_changes;
173 }
174 mas_config.account.displayname_change_allowed = self.enable_set_display_name;
175 if self.password_config.enabled {
176 mas_config.passwords.enabled = true;
177 mas_config.passwords.schemes = vec![
178 PasswordHashingScheme {
180 version: 1,
181 algorithm: PasswordAlgorithm::Bcrypt,
182 cost: self.bcrypt_rounds,
183 secret: self.password_config.pepper,
184 secret_file: None,
185 unicode_normalization: true,
186 },
187 PasswordHashingScheme {
190 version: 2,
191 algorithm: PasswordAlgorithm::default(),
192 cost: None,
193 secret: None,
194 secret_file: None,
195 unicode_normalization: false,
196 },
197 ];
198
199 mas_config.account.password_registration_enabled = self.enable_registration;
200 } else {
201 mas_config.passwords.enabled = false;
202 }
203
204 if self.enable_registration_captcha {
205 mas_config.captcha.service = Some(mas_config::CaptchaServiceKind::RecaptchaV2);
206 mas_config.captcha.site_key = self.recaptcha_public_key;
207 mas_config.captcha.secret_key = self.recaptcha_private_key;
208 }
209
210 mas_config.matrix.homeserver = self.server_name;
211 if let Some(public_baseurl) = self.public_baseurl {
212 mas_config.matrix.endpoint = public_baseurl;
213 }
214
215 mas_config
216 }
217}
218
219#[derive(Deserialize)]
223pub struct DatabaseSection {
224 pub name: String,
228 #[serde(default)]
229 pub args: DatabaseArgsSuboption,
230}
231
232pub const SYNAPSE_DATABASE_DRIVER_NAME_PSYCOPG2: &str = "psycopg2";
234pub const SYNAPSE_DATABASE_DRIVER_NAME_SQLITE3: &str = "sqlite3";
236
237impl DatabaseSection {
238 pub fn to_sqlx_postgres(&self) -> Result<PgConnectOptions, anyhow::Error> {
251 if self.name != SYNAPSE_DATABASE_DRIVER_NAME_PSYCOPG2 {
252 anyhow::bail!("syn2mas does not support the {} database driver", self.name);
253 }
254
255 if self.args.database.is_some() && self.args.dbname.is_some() {
256 anyhow::bail!(
257 "Only one of `database` and `dbname` may be specified in the Synapse database configuration, not both."
258 );
259 }
260
261 let mut opts = PgConnectOptions::new().application_name("syn2mas-synapse");
262
263 if let Some(host) = &self.args.host {
264 opts = opts.host(host);
265 }
266 if let Some(port) = self.args.port {
267 opts = opts.port(port);
268 }
269 if let Some(dbname) = &self.args.dbname {
270 opts = opts.database(dbname);
271 }
272 if let Some(database) = &self.args.database {
273 opts = opts.database(database);
274 }
275 if let Some(user) = &self.args.user {
276 opts = opts.username(user);
277 }
278 if let Some(password) = &self.args.password {
279 opts = opts.password(password);
280 }
281
282 Ok(opts)
283 }
284}
285
286#[derive(Deserialize, Default)]
290pub struct DatabaseArgsSuboption {
291 pub user: Option<String>,
292 pub password: Option<String>,
293 pub dbname: Option<String>,
294 pub database: Option<String>,
296 pub host: Option<String>,
297 pub port: Option<u16>,
298}
299
300#[derive(Deserialize)]
304pub struct PasswordSection {
305 #[serde(default = "default_true")]
306 pub enabled: bool,
307 #[serde(default = "default_true")]
308 pub localdb_enabled: bool,
309 pub pepper: Option<String>,
310}
311
312impl Default for PasswordSection {
313 fn default() -> Self {
314 PasswordSection {
315 enabled: true,
316 localdb_enabled: true,
317 pepper: None,
318 }
319 }
320}
321
322#[derive(Default, Deserialize)]
325pub struct EnableableSection {
326 #[serde(default)]
327 pub enabled: bool,
328}
329
330fn default_true() -> bool {
331 true
332}
333
334#[cfg(test)]
335mod test {
336 use sqlx::postgres::PgConnectOptions;
337
338 use super::{DatabaseArgsSuboption, DatabaseSection};
339
340 #[test]
341 fn test_to_sqlx_postgres() {
342 #[track_caller]
343 #[expect(clippy::needless_pass_by_value)]
344 fn assert_eq_options(config: DatabaseSection, uri: &str) {
345 let config_connect_options = config
346 .to_sqlx_postgres()
347 .expect("no connection options generated by config");
348 let uri_connect_options: PgConnectOptions = uri
349 .parse()
350 .expect("example URI did not parse as PgConnectionOptions");
351
352 assert_eq!(
353 config_connect_options.get_host(),
354 uri_connect_options.get_host()
355 );
356 assert_eq!(
357 config_connect_options.get_port(),
358 uri_connect_options.get_port()
359 );
360 assert_eq!(
361 config_connect_options.get_username(),
362 uri_connect_options.get_username()
363 );
364 assert_eq!(
366 config_connect_options.get_database(),
367 uri_connect_options.get_database()
368 );
369 }
370
371 assert!(
373 DatabaseSection {
374 name: "sqlite3".to_owned(),
375 args: DatabaseArgsSuboption::default(),
376 }
377 .to_sqlx_postgres()
378 .is_err()
379 );
380
381 assert!(
383 DatabaseSection {
384 name: "psycopg2".to_owned(),
385 args: DatabaseArgsSuboption {
386 user: Some("synapse_user".to_owned()),
387 password: Some("verysecret".to_owned()),
388 dbname: Some("synapse_db".to_owned()),
389 database: Some("synapse_db".to_owned()),
390 host: Some("synapse-db.example.com".to_owned()),
391 port: Some(42),
392 },
393 }
394 .to_sqlx_postgres()
395 .is_err()
396 );
397
398 assert_eq_options(
399 DatabaseSection {
400 name: "psycopg2".to_owned(),
401 args: DatabaseArgsSuboption::default(),
402 },
403 "postgresql:///",
404 );
405 assert_eq_options(
406 DatabaseSection {
407 name: "psycopg2".to_owned(),
408 args: DatabaseArgsSuboption {
409 user: Some("synapse_user".to_owned()),
410 password: Some("verysecret".to_owned()),
411 dbname: Some("synapse_db".to_owned()),
412 database: None,
413 host: Some("synapse-db.example.com".to_owned()),
414 port: Some(42),
415 },
416 },
417 "postgresql://synapse_user:verysecret@synapse-db.example.com:42/synapse_db",
418 );
419 }
420}
421
422#[derive(Deserialize)]
425pub struct UserConsentSection {}